Nahemi commited on
Commit
ed60762
·
verified ·
1 Parent(s): 07800e7

Upload 7 files

Browse files

Mise en place du MCP

Files changed (7) hide show
  1. .gitattributes +35 -35
  2. .gitignore +6 -0
  3. README.md +12 -12
  4. app.py +371 -39
  5. gradio_mcp_server.py +433 -0
  6. requirements.txt +6 -1
  7. tool_utils.py +56 -0
.gitattributes CHANGED
@@ -1,35 +1,35 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv
2
+ .env
3
+ __pycache__/
4
+ *.pyc
5
+ instance
6
+ .pytest_cache/
README.md CHANGED
@@ -1,12 +1,12 @@
1
- ---
2
- title: Hackathon Os Farm
3
- emoji: 🔥
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 5.46.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ---
2
+ title: Chatbot Hackathon
3
+ emoji: 📈
4
+ colorFrom: pink
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 5.46.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -1,39 +1,371 @@
1
- import requests
2
- import gradio as gr
3
-
4
- def get_random_quote(lang='en', format='json'):
5
- """
6
- Fetches a random quote from the Forismatic API.
7
-
8
- Args:
9
- lang (str): Language code for the quote. Default is 'en'.
10
- format (str): Response format. Default is 'json'.
11
-
12
- Returns:
13
- str: Formatted quote string with author.
14
- """
15
- url = "https://lexicon.osfarm.org/viticulture/vine-varieties.json"
16
- params = {
17
- 'format': format
18
- }
19
-
20
- response = requests.get(url, params=params)
21
- response.raise_for_status()
22
- data = response.json()
23
-
24
- quote = data.get('quoteText', 'No quote available').strip()
25
- author = data.get('quoteAuthor', 'Unknown').strip()
26
-
27
- return f'"{data}"'
28
-
29
- # Create Gradio interface
30
- app = gr.Interface(
31
- fn=get_random_quote,
32
- inputs=[], # no input needed
33
- outputs=gr.Textbox(label="Random Quote"),
34
- title="Random Quote Generator",
35
- description="Click the button to get an inspiring random quote."
36
- )
37
-
38
- if __name__ == "__main__":
39
- app.launch(mcp_server=True, share=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from typing import List, Dict, Any, Optional
4
+ from contextlib import AsyncExitStack
5
+
6
+ import gradio as gr
7
+
8
+
9
+ import asyncio, os
10
+ from typing import List, Dict, Any, Optional
11
+ import gradio as gr
12
+ from mcp import ClientSession, StdioServerParameters
13
+ from mcp.client.stdio import stdio_client
14
+ from anthropic import Anthropic
15
+ from dotenv import load_dotenv
16
+ from tool_utils import filter_tools_for_context, summarize_latest_results, count_tokens, trim_conversation
17
+
18
+ load_dotenv()
19
+
20
+ import os
21
+ import re
22
+ import asyncio
23
+ import logging
24
+ from typing import List, Dict, Any, Optional, Tuple
25
+ from contextlib import AsyncExitStack
26
+ from anthropic import Anthropic
27
+ from mcp.client.session import ClientSession
28
+ from mcp.client.stdio import stdio_client
29
+ from mcp.client.stdio import StdioServerParameters
30
+
31
+ # Logger configuré
32
+ logging.basicConfig(level=logging.INFO)
33
+ logger = logging.getLogger("MCPClient")
34
+
35
+ MAX_HISTORY_MESSAGES = 5
36
+
37
+
38
+ def retry_async(max_attempts: int = 3, delay: float = 1.0):
39
+ """Décorateur de retry pour fonctions async."""
40
+ def decorator(func):
41
+ async def wrapper(*args, **kwargs):
42
+ for attempt in range(1, max_attempts + 1):
43
+ try:
44
+ return await func(*args, **kwargs)
45
+ except Exception as e:
46
+ if attempt == max_attempts:
47
+ raise
48
+ logger.warning(f"Échec tentative {attempt}/{max_attempts} : {e}. Retry dans {delay}s...")
49
+ await asyncio.sleep(delay)
50
+ # Ne devrait jamais arriver
51
+ raise RuntimeError("Retry loop exited unexpectedly")
52
+ return wrapper
53
+ return decorator
54
+
55
+
56
+ class MCPClient:
57
+ """Client MCP robuste avec gestion de connexion et retries."""
58
+
59
+ def __init__(self):
60
+ self.loop = asyncio.new_event_loop()
61
+ asyncio.set_event_loop(self.loop)
62
+ self.session: Optional[ClientSession] = None
63
+ self.tools: List[Dict[str, Any]] = []
64
+ self.connected: bool = False
65
+ self.max_iterations: int = 3
66
+ self.client: Optional[Anthropic] = None
67
+ self.exit_stack: Optional[AsyncExitStack] = None
68
+ self._init_client()
69
+
70
+ def _init_client(self):
71
+ key = os.getenv("ANTHROPIC_API_KEY")
72
+ if not key:
73
+ raise EnvironmentError("❌ ANTHROPIC_API_KEY manquant dans l'environnement")
74
+ self.client = Anthropic()
75
+
76
+ def connect(self) -> str:
77
+ """Connexion synchrone MCP (wrap async)."""
78
+ return self.loop.run_until_complete(self._connect())
79
+
80
+ @retry_async(max_attempts=3, delay=2)
81
+ async def _connect(self) -> str:
82
+ """Connexion asynchrone avec MCP via stdio."""
83
+ if self.exit_stack:
84
+ await self.exit_stack.aclose()
85
+
86
+ self.exit_stack = AsyncExitStack()
87
+
88
+ params = StdioServerParameters(
89
+ command="python",
90
+ args=["gradio_mcp_server.py"],
91
+ env={"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"},
92
+ )
93
+
94
+ try:
95
+ stdio_transport = await self.exit_stack.enter_async_context(stdio_client(params))
96
+ self.stdio, self.write = stdio_transport
97
+ self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
98
+ await self.session.initialize()
99
+
100
+ resp = await self.session.list_tools()
101
+ self.tools = [
102
+ {"name": t.name, "description": t.description, "input_schema": t.inputSchema}
103
+ for t in resp.tools
104
+ ]
105
+
106
+ self.connected = True
107
+ return f"✅ MCP connecté ({len(self.tools)} outils disponibles)"
108
+ except Exception as e:
109
+ self.connected = False
110
+ return f"❌ Connexion MCP échouée : {e}"
111
+
112
+ def _read_file(self, path: str) -> str:
113
+ """Lecture robuste de fichiers selon leur extension."""
114
+ import PyPDF2
115
+
116
+ ext = os.path.splitext(path)[1].lower()
117
+ try:
118
+ if ext in [".txt", ".md", ".py", ".json", ".csv"]:
119
+ with open(path, "r", encoding="utf-8") as f:
120
+ return f.read()
121
+ elif ext == ".pdf":
122
+ with open(path, "rb") as f:
123
+ return "\n".join(page.extract_text() for page in PyPDF2.PdfReader(f).pages)
124
+ else:
125
+ with open(path, "r", encoding="utf-8") as f:
126
+ return f.read()
127
+ except Exception as e:
128
+ return f"[Erreur lecture fichier {os.path.basename(path)}: {e}]"
129
+
130
+ def process_message(
131
+ self, message: str, files: Optional[List] = None, history: Optional[List[List[str]]] = None
132
+ ) -> Tuple[List[List[str]], str, None]:
133
+ """Pipeline haut-niveau (message + fichiers → réponse)."""
134
+ if not self.session or not self.connected:
135
+ return history + [[message, "❌ Serveur MCP non connecté."]], "", None
136
+
137
+ file_content = ""
138
+ if files:
139
+ for file in files:
140
+ path = getattr(file, "name", file)
141
+ file_content += f"\nFichier: {os.path.basename(path)}\n{self._read_file(path)}\n"
142
+
143
+ full_message = (file_content + message).strip()
144
+ new_msgs = self.loop.run_until_complete(self._process_query(full_message, history or []))
145
+ assistant_reply = "\n\n".join(m.get("content", "") for m in new_msgs if m.get("role") == "assistant")
146
+
147
+ return (history or []) + [[message, assistant_reply]], "", None
148
+
149
+ async def _process_query(self, message: str, history: List[Any]):
150
+ """Exécution de la requête utilisateur avec gestion outils."""
151
+ if not self.client:
152
+ return [{"role": "assistant", "content": "❌ Client Claude indisponible."}]
153
+
154
+ # Prompt système (LEXICON)
155
+ sys_prompt = (
156
+ "You are LEXICON, an intelligent agricultural and weather data assistant with access to "
157
+ "specialized tools. Your mission: produce complete, accurate answers using planning + multiple tool calls."
158
+ )
159
+
160
+ conv = [{"role": r, "content": c} for h in history for r, c in zip(["user", "assistant"], h)]
161
+ conv.append({"role": "user", "content": message})
162
+
163
+ try:
164
+ return await self._tool_loop(conv, sys_prompt)
165
+ except Exception as e:
166
+ return [{"role": "assistant", "content": f"❌ Erreur Claude : {e}"}]
167
+
168
+ @retry_async(max_attempts=3, delay=2)
169
+ async def _tool_loop(self, messages: List[Dict[str, str]], sys_prompt: str):
170
+ """Boucle principale de planification/exécution avec outils MCP."""
171
+ result_msgs: List[Dict[str, str]] = []
172
+ conv = messages.copy()
173
+ seen_tool_calls = set()
174
+ iteration = 0
175
+ last_summary = None
176
+ max_context_tokens = 2000
177
+ tool_timeout = 10.0
178
+
179
+ while iteration < self.max_iterations:
180
+ iteration += 1
181
+ tools_this_round = filter_tools_for_context(self.tools, conv, [])
182
+
183
+ try:
184
+ resp = self.client.messages.create(
185
+ model=os.getenv("CLAUDE_MODEL", "claude-3-5-sonnet-20241022"),
186
+ max_tokens=int(os.getenv("CLAUDE_MAX_TOKENS", "8192")),
187
+ system=sys_prompt,
188
+ messages=conv,
189
+ tools=tools_this_round,
190
+ )
191
+ except Exception as e:
192
+ result_msgs.append({"role": "assistant", "content": f"❌ Erreur appel modèle : {e}"})
193
+ break
194
+
195
+ has_tool_calls = False
196
+ iteration_changes = False
197
+
198
+ for c in resp.content:
199
+ if c.type == "tool_use":
200
+ has_tool_calls = True
201
+ tool_name, tool_args, tool_call_id = c.name, c.input, c.id
202
+ key = (tool_name, tuple(sorted(tool_args.items())))
203
+ if key in seen_tool_calls:
204
+ result_msgs.append({"role": "assistant", "content": f"ℹ️ Tool déjà appelé {tool_name}({tool_args})"})
205
+ continue
206
+ seen_tool_calls.add(key)
207
+
208
+ try:
209
+ tool_result = await asyncio.wait_for(
210
+ self.session.call_tool(tool_name, tool_args), timeout=tool_timeout
211
+ )
212
+ raw_str = "\n".join(str(item) for item in tool_result.content)
213
+ conv.extend([
214
+ {"role": "assistant", "content": [{"type": "tool_use", "id": tool_call_id, "name": tool_name, "input": tool_args}]},
215
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool_call_id, "content": raw_str}]}
216
+ ])
217
+ result_msgs.append({"role": "assistant", "content": f"🔧 {tool_name}({tool_args})\n```json\n{raw_str}\n```"})
218
+ iteration_changes = True
219
+ except asyncio.TimeoutError:
220
+ msg = f"❌ Timeout outil {tool_name}({tool_args})"
221
+ result_msgs.append({"role": "assistant", "content": msg})
222
+ except Exception as e:
223
+ msg = f"❌ Erreur outil {tool_name}({tool_args}) : {e}"
224
+ result_msgs.append({"role": "assistant", "content": msg})
225
+
226
+ elif c.type == "text":
227
+ text = c.text.strip()
228
+ if text:
229
+ result_msgs.append({"role": "assistant", "content": text})
230
+ conv.append({"role": "assistant", "content": text})
231
+ iteration_changes = True
232
+
233
+ # Conditions d'arrêt
234
+ if not has_tool_calls or not iteration_changes:
235
+ break
236
+
237
+ summary = summarize_latest_results(conv)
238
+ if last_summary is not None and summary == last_summary:
239
+ result_msgs.append({"role": "assistant", "content": "ℹ️ Pas de nouvelles infos, arrêt."})
240
+ break
241
+ last_summary = summary
242
+
243
+ if max_context_tokens and count_tokens(conv) > max_context_tokens:
244
+ conv = trim_conversation(conv, keep_last_n=MAX_HISTORY_MESSAGES)
245
+
246
+ # Synthèse finale
247
+ result_msgs.append({"role": "assistant", "content": "## 📋 Synthèse finale :"})
248
+ try:
249
+ final_prompt = "Basé sur les données collectées, rédige une réponse claire et utile à la question initiale."
250
+ conv.append({"role": "user", "content": final_prompt})
251
+ final_resp = self.client.messages.create(
252
+ model=os.getenv("CLAUDE_MODEL", "claude-3-5-sonnet-20241022"),
253
+ max_tokens=int(os.getenv("CLAUDE_MAX_TOKENS", "8192")),
254
+ system="You are the assistant producing the final analysis.",
255
+ messages=conv,
256
+ tools=[],
257
+ )
258
+ for c in final_resp.content:
259
+ if c.type == "text":
260
+ result_msgs.append({"role": "assistant", "content": c.text.strip()})
261
+ except Exception as e:
262
+ result_msgs.append({"role": "assistant", "content": f"❌ Erreur synthèse finale : {e}"})
263
+
264
+ return result_msgs
265
+
266
+ client = MCPClient()
267
+
268
+ def gradio_interface():
269
+ # Keep the custom orange and red theme
270
+ theme = gr.themes.Default(
271
+ primary_hue=gr.themes.colors.orange,
272
+ secondary_hue=gr.themes.colors.red,
273
+ neutral_hue=gr.themes.colors.slate,
274
+ )
275
+
276
+ with gr.Blocks(title="MCP LEXICON", theme=theme, css=".gradio-container {max-width: 95% !important;}") as demo:
277
+
278
+ # 1. Top row with title and the new dynamic status button
279
+ with gr.Row():
280
+ with gr.Column(scale=8):
281
+ gr.Markdown("## 🌾 LEXICON CHATBOT")
282
+ with gr.Column(scale=10, min_width=220):
283
+ status_button = gr.Button(
284
+ "Connecting...",
285
+ variant="stop",
286
+ interactive=False
287
+ )
288
+
289
+ # 2. Main chat interface with a clear button
290
+ with gr.Row():
291
+ chatbot = gr.Chatbot(
292
+ label="Conversation",
293
+ value=[],
294
+ height=650,
295
+ show_copy_button=True,
296
+ avatar_images=("👤", "🌾"),
297
+ bubble_full_width=False,
298
+ )
299
+ clear_btn = gr.Button("🗑️ Clear", scale=0)
300
+
301
+ # 3. Concise input bar at the bottom (standard chatbot layout)
302
+ with gr.Row():
303
+ with gr.Column(scale=10):
304
+ msg = gr.Textbox(
305
+ label="User Prompt",
306
+ placeholder="Ask a question about agriculture, weather, or geography...",
307
+ show_label=False,
308
+ container=False # Removes border for a cleaner look
309
+ )
310
+
311
+ file_btn = gr.UploadButton("📎", file_count="multiple", scale=1)
312
+
313
+ submit_btn = gr.Button(
314
+ "Ask",
315
+ variant="primary",
316
+ scale=1
317
+ )
318
+
319
+ # Examples accordion remains at the bottom
320
+ with gr.Accordion("💡 Example Queries", open=False):
321
+ gr.Examples(
322
+ examples=[
323
+ "What's the complete agricultural profile of Bignan including weather stations, cadastral parcels, and production data?",
324
+ "Find all weather stations near Paris, get their latest data, and analyze weather patterns",
325
+ "I need comprehensive information about vine varieties and which phytosanitary products are recommended for vineyard management",
326
+ ],
327
+ inputs=msg
328
+ )
329
+
330
+ # Event handlers
331
+ def auto_connect():
332
+ return client.connect()
333
+
334
+ def process_and_clear(message, files, history):
335
+ if not message.strip() and not files:
336
+ return history, "", None
337
+ # Simply return the result from the client method
338
+ return client.process_message(message, files, history)
339
+
340
+ # Setup events
341
+ demo.load(auto_connect, outputs=status_button)
342
+ status_button.click(auto_connect, outputs=status_button)
343
+
344
+ submit_btn.click(
345
+ process_and_clear,
346
+ inputs=[msg, file_btn, chatbot],
347
+ outputs=[chatbot, msg, file_btn]
348
+ )
349
+
350
+ msg.submit(
351
+ process_and_clear,
352
+ inputs=[msg, file_btn, chatbot],
353
+ outputs=[chatbot, msg, file_btn]
354
+ )
355
+
356
+ clear_btn.click(lambda: ([], "", None), outputs=[chatbot, msg, file_btn], queue=False)
357
+
358
+ return demo
359
+
360
+ if __name__ == "__main__":
361
+ if not os.getenv("ANTHROPIC_API_KEY"):
362
+ print("Warning: ANTHROPIC_API_KEY not found in environment.")
363
+ print("Please set it in your .env file: ANTHROPIC_API_KEY=your_key_here")
364
+ else:
365
+ print("Found Anthropic API key")
366
+
367
+ print("Starting Enhanced MCP Client with Multi-Step Planning...")
368
+ print("API endpoint: https://lexicon.osfarm.org")
369
+
370
+ interface = gradio_interface()
371
+ interface.launch(debug=True, share=True)
gradio_mcp_server.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mcp.server.fastmcp import FastMCP
2
+ import json
3
+ import sys
4
+ import io
5
+ import requests
6
+
7
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
8
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
9
+
10
+ mcp = FastMCP("lexicon_api_server")
11
+
12
+ BASE_URL = "https://lexicon.osfarm.org"
13
+
14
+ def make_api_request(endpoint: str, params: dict = None) -> str:
15
+ """Helper function to make API requests with consistent error handling"""
16
+ url = f"{BASE_URL}{endpoint}"
17
+
18
+ try:
19
+ response = requests.get(url, params=params, timeout=30)
20
+ response.raise_for_status()
21
+
22
+ # Check if response is GeoJSON or regular JSON
23
+ content_type = response.headers.get('content-type', '')
24
+ if 'application/geo+json' in content_type or endpoint.endswith('.geojson'):
25
+ data_type = "geojson"
26
+ else:
27
+ data_type = "json"
28
+
29
+ data = response.json()
30
+
31
+ return json.dumps({
32
+ "type": "success",
33
+ "data_type": data_type,
34
+ "endpoint": endpoint,
35
+ "data": data,
36
+ "message": f"Successfully retrieved data from {endpoint}"
37
+ }, indent=2)
38
+
39
+ except requests.exceptions.ConnectionError:
40
+ return json.dumps({
41
+ "type": "error",
42
+ "endpoint": endpoint,
43
+ "message": f"Could not connect to API. Please ensure the service is running."
44
+ })
45
+ except requests.exceptions.Timeout:
46
+ return json.dumps({
47
+ "type": "error",
48
+ "endpoint": endpoint,
49
+ "message": f"Request timed out for {endpoint}"
50
+ })
51
+ except requests.exceptions.HTTPError as e:
52
+ status_code = e.response.status_code if e.response else "unknown"
53
+ return json.dumps({
54
+ "type": "error",
55
+ "endpoint": endpoint,
56
+ "status_code": status_code,
57
+ "message": f"HTTP error {status_code} for {endpoint}. Resource may not exist or API may be unavailable."
58
+ })
59
+ except json.JSONDecodeError:
60
+ return json.dumps({
61
+ "type": "error",
62
+ "endpoint": endpoint,
63
+ "message": f"Invalid JSON response from {url}"
64
+ })
65
+ except Exception as e:
66
+ return json.dumps({
67
+ "type": "error",
68
+ "endpoint": endpoint,
69
+ "message": f"Unexpected error: {str(e)}"
70
+ })
71
+
72
+ @mcp.tool()
73
+ async def get_parcel_identifier_json(latitude: float, longitude: float) -> str:
74
+ """
75
+ Retrieve parcel identifier information in JSON format for a given geographic location. -- ST PORCHAIRE
76
+
77
+ Args:
78
+ latitude (float): Latitude of the point of interest.
79
+ longitude (float): Longitude of the point of interest.
80
+
81
+ Returns:
82
+ str: JSON string containing parcel identifier data for the specified coordinates.
83
+
84
+ This tool allows you to obtain parcel identification data by providing precise geographic coordinates.
85
+ Useful for reverse-geocoding a location to its cadastral reference.
86
+ """
87
+ params = {"latitude": latitude, "longitude": longitude}
88
+ return make_api_request("/tools/parcel-identifier.json", params)[:20000]
89
+
90
+ @mcp.tool()
91
+ async def get_cadastral_parcels(
92
+ page: int = 1,
93
+ code: str = None,
94
+ prefix: str = None,
95
+ section: str = None,
96
+ number: str = None
97
+ ) -> str:
98
+ """
99
+ Retrieve a paginated list of cadastral parcels, with optional filters.
100
+
101
+ Args:
102
+ page (int, optional): Page number for pagination (default: 1).
103
+ code (str, optional): Commune code to filter parcels.
104
+ prefix (str, optional): Parcel prefix for more precise filtering.
105
+ section (str, optional): Parcel section identifier.
106
+ number (str, optional): Parcel number.
107
+
108
+ Returns:
109
+ str: JSON string containing a list of cadastral parcels matching the filters.
110
+
111
+ This tool enables searching for cadastral parcels using various administrative and parcel-specific filters.
112
+ Useful for exploring land registry data at different levels of granularity.
113
+ Using a single postal_code, gives every cadastral parcels codes in that city, for example.
114
+ """
115
+ params = {"page": page}
116
+ if code: params["code"] = code
117
+ if prefix: params["prefix"] = prefix
118
+ if section: params["section"] = section
119
+ if number: params["number"] = number
120
+ return make_api_request("/geographical-references/cadastral-parcels.json", params)
121
+
122
+ @mcp.tool()
123
+ async def get_cadastral_parcel(parcel_id: str) -> str:
124
+ """
125
+ Retrieve detailed information about a specific cadastral parcel.
126
+
127
+ Args:
128
+ parcel_id (str): Unique identifier of the cadastral parcel.
129
+
130
+ Returns:
131
+ str: JSON string with detailed information about the parcel.
132
+
133
+ Use this tool to get all available data for a single cadastral parcel, including administrative and spatial attributes.
134
+ """
135
+ return make_api_request(f"/geographical-references/cadastral-parcels/{parcel_id}.json")
136
+
137
+
138
+ @mcp.tool()
139
+ async def get_cadastral_parcel_prices(postal_code: str = None, city: str = None, department: str = None) -> str:
140
+ """
141
+ Retrieve cadastral parcel price information, filtered by postal code, city, or department,
142
+
143
+ Args:
144
+ postal_code (str, optional): Postal code to filter results.
145
+ city (str, optional): City name for filtering.
146
+ department (str, optional): Department code or name.
147
+
148
+ Returns:
149
+ str: JSON string with price information for cadastral parcels.
150
+
151
+ This tool provides access to price data for cadastral parcels, supporting multiple administrative filters.
152
+ It is possible, with a postal code and a city name, to get the prices of every cadastral parcel sold in that city and when,
153
+ for example. So it is possible to know which one is the most expensive or the cheapest.
154
+ """
155
+ params = {}
156
+ if postal_code: params["postal_code"] = postal_code
157
+ if city: params["city"] = city
158
+ if department: params["department"] = department
159
+ ## FOR DEMO PURPOSES:
160
+ params["page"] = 2
161
+ return make_api_request("/geographical-references/cadastral-parcel-prices.json", params)
162
+
163
+ # geographical-references/cadastral-parcel-prices?postal_code=&city=Saint-Porchaire&department=&page=2
164
+
165
+ @mcp.tool()
166
+ async def get_cap_parcels(page: int = 1, city: str = None) -> str:
167
+ """
168
+ Retrieve a paginated list of CAP (Common Agricultural Policy) parcels,
169
+ allowing to access the crops available in the filtered city.
170
+
171
+ Args:
172
+ page (int, optional): Page number for pagination (default: 1).
173
+ city (str, optional): City name to filter CAP parcels.
174
+
175
+ Returns:
176
+ str: JSON string containing CAP parcels matching the filters.
177
+
178
+ This tool allows you to explore CAP parcels, which are relevant for agricultural policy and subsidy management.
179
+ """
180
+ return make_api_request(f"/geographical-references/cap-parcels.json?city={city}")
181
+
182
+ @mcp.tool()
183
+ async def get_municipalities(page: int = 1, country: str = None, city: str = None) -> str:
184
+ """
185
+ Retrieve a paginated list of municipalities, with optional filters for country and city.
186
+
187
+ Args:
188
+ page (int, optional): Page number for pagination (default: 1).
189
+ country (str, optional): Country name to filter municipalities.
190
+ city (str, optional): City name for more precise filtering.
191
+
192
+ Returns:
193
+ str: JSON string containing municipalities matching the filters.
194
+
195
+ This tool is useful for exploring administrative boundaries and locating municipalities by name or country.
196
+ """
197
+ params = {"page": page}
198
+ if country: params["country"] = country
199
+ if city: params["city"] = city
200
+ return make_api_request("/geographical-references/municipalities.json", params)
201
+
202
+ @mcp.tool()
203
+ async def get_municipality(municipality_id: str) -> str:
204
+ """
205
+ Retrieve detailed information about a specific municipality.
206
+
207
+ Args:
208
+ municipality_id (str): Unique identifier of the municipality.
209
+
210
+ Returns:
211
+ str: JSON string with detailed information about the municipality.
212
+
213
+ Use this tool to access all available data for a single municipality, including administrative and spatial attributes.
214
+ """
215
+ return make_api_request(f"/geographical-references/municipalities/{municipality_id}.json")
216
+
217
+ @mcp.tool()
218
+ async def get_productions(page: int = 1, family: str = None, usage: str = None) -> str:
219
+ """
220
+ Retrieve a paginated list of production data, with optional filters for family and usage.
221
+
222
+ Args:
223
+ page (int, optional): Page number for pagination (default: 1).
224
+ family (str, optional): Production family (e.g., crop type).
225
+ usage (str, optional): Usage type (e.g., food, feed).
226
+
227
+ Returns:
228
+ str: JSON string containing production data matching the filters.
229
+
230
+ This tool is useful for analyzing agricultural production by type and intended use.
231
+ """
232
+ params = {"page": page}
233
+ if family: params["family"] = family
234
+ if usage: params["usage"] = usage
235
+ return make_api_request("/production/productions.json", params)
236
+
237
+ @mcp.tool()
238
+ async def get_cropsets(page: int = 1) -> str:
239
+ """
240
+ Retrieve a paginated list of phytosanitary cropsets.
241
+
242
+ Args:
243
+ page (int, optional): Page number for pagination (default: 1).
244
+
245
+ Returns:
246
+ str: JSON string containing cropset data.
247
+
248
+ This tool provides access to phytosanitary cropsets, which are important for plant protection and regulatory compliance.
249
+ """
250
+ params = {"page": page}
251
+ return make_api_request("/phytosanitary/cropsets.json", params)
252
+
253
+ @mcp.tool()
254
+ async def get_phytosanitary_products(page: int = 1) -> str:
255
+ """
256
+ Retrieve a paginated list of phytosanitary products, and returns:
257
+ Name
258
+ firm
259
+ Type
260
+ Active compounds
261
+ Usage state
262
+
263
+ Args:
264
+ page (int, optional): Page number for pagination (default: 1).
265
+ type (str, optional): Product type (e.g., herbicide, fungicide).
266
+ state (str, optional): Product state (e.g., approved, withdrawn).
267
+
268
+ Returns:
269
+ str: JSON string containing phytosanitary products matching the filters.
270
+
271
+ This tool is useful for exploring available plant protection products and their regulatory status.
272
+ """
273
+ return make_api_request("/phytosanitary/products.json")
274
+
275
+ @mcp.tool()
276
+ async def get_phytosanitary_symbols(page: int = 1) -> str:
277
+ """
278
+ Retrieve a paginated list of phytosanitary symbols.
279
+
280
+ Args:
281
+ page (int, optional): Page number for pagination (default: 1).
282
+
283
+ Returns:
284
+ str: JSON string containing phytosanitary symbols.
285
+
286
+ This tool provides access to symbols used in plant protection and regulatory documentation.
287
+ """
288
+ params = {"page": page}
289
+ return make_api_request("/phytosanitary/symbols.json", params)
290
+
291
+ @mcp.tool()
292
+ async def get_seed_varieties(page: int = 1, species: str = None) -> str:
293
+ """
294
+ Retrieve a paginated list of seed varieties, optionally filtered by species.
295
+
296
+ Args:
297
+ page (int, optional): Page number for pagination (default: 1).
298
+ species (str, optional): Species name to filter seed varieties, ALWAYS IN CAPITAL LETTERS, e.g for "avoine", use "AVOINE",
299
+ to find the right species, for example "AVOINE". Then varieties will be filtered.
300
+
301
+ Returns:
302
+ str: JSON string containing seed varieties matching the filters.
303
+
304
+ This tool is useful for exploring available seed varieties for different crops.
305
+ """
306
+ params = {"page": page}
307
+ if species: params["species"] = species
308
+ return make_api_request("/seeds/varieties.json", params)
309
+
310
+
311
+ @mcp.tool()
312
+ async def get_vine_varieties(page: int = 1, category: str = None, color: str = None) -> str:
313
+ """
314
+ Retrieve a paginated list of vine varieties, with optional filters for category and color.
315
+
316
+ Args:
317
+ page (int, optional): Page number for pagination (default: 1).
318
+ category (str, optional): Vine category (e.g., table, wine).
319
+ color (str, optional): Grape color (e.g., red, white).
320
+
321
+ Returns:
322
+ str: JSON string containing vine varieties matching the filters.
323
+
324
+ This tool is useful for exploring grapevine diversity and selecting varieties for viticulture.
325
+ """
326
+ params = {"page": page}
327
+ if category: params["category"] = category
328
+ if color: params["color"] = color
329
+ return make_api_request("/viticulture/vine-varieties.json", params)
330
+
331
+ @mcp.tool()
332
+ async def get_weather_stations(page: int = 1, country: str = None, name: str = None) -> str:
333
+ """
334
+ Retrieve a paginated list of weather stations, with optional filters for country and station name.
335
+
336
+ Args:
337
+ page (int, optional): Page number for pagination (default: 1).
338
+ country (str, optional): Country name to filter stations.
339
+ name (str, optional): Station name for more precise filtering.
340
+
341
+ Returns:
342
+ str: JSON string containing weather stations matching the filters.
343
+
344
+ This tool is useful for discovering available weather stations and narrowing down by location or name.
345
+ """
346
+ params = {"page": page}
347
+ if country: params["country"] = country
348
+ if name: params["name"] = name
349
+ return make_api_request("/weather/stations.json", params)
350
+
351
+ @mcp.tool()
352
+ async def get_weather_station(station_code: str) -> str:
353
+ """
354
+ Retrieve detailed information about a specific weather station.
355
+
356
+ Args:
357
+ station_code (str): Unique code identifying the weather station.
358
+
359
+ Returns:
360
+ str: JSON string with detailed information about the weather station.
361
+
362
+ Use this tool to access metadata and attributes for a single weather station.
363
+ """
364
+ return make_api_request(f"/weather/stations/{station_code}.json")
365
+
366
+ @mcp.tool()
367
+ async def get_weather_data(station_code: str, start: str = None, end: str = None) -> str:
368
+ """
369
+ Retrieve hourly weather reports for a specific station, optionally filtered by date range.
370
+
371
+ Args:
372
+ station_code (str): Unique code identifying the weather station.
373
+ start (str, optional): Start date/time in ISO format (e.g., '2024-01-01T00:00:00Z').
374
+ end (str, optional): End date/time in ISO format (e.g., '2024-01-31T23:59:59Z').
375
+
376
+ Returns:
377
+ str: JSON string containing hourly weather reports for the specified station and date range.
378
+
379
+ This tool is useful for analyzing weather data over time for a given location.
380
+ """
381
+ params = {}
382
+ if start: params["start"] = start
383
+ if end: params["end"] = end
384
+ return make_api_request(f"/weather/stations/{station_code}/hourly-reports.json", params)
385
+
386
+
387
+ @mcp.tool()
388
+ async def get_cadastral_parcel_geolocation(parcel_id: str) -> str:
389
+ """
390
+ Retrieve geolocation data for a specific cadastral parcel in GeoJSON format.
391
+
392
+ Args:
393
+ parcel_id (str): Unique identifier of the cadastral parcel.
394
+
395
+ Returns:
396
+ str: GeoJSON string with spatial data for the parcel.
397
+
398
+ Use this tool to obtain the geometry of a cadastral parcel for mapping or spatial analysis.
399
+ """
400
+ return make_api_request(f"/geographical-references/cadastral-parcels/{parcel_id}/geolocation.geojson")
401
+
402
+ @mcp.tool()
403
+ async def get_cap_parcel_geolocation(cap_id: str) -> str:
404
+ """
405
+ Retrieve geolocation data for a specific CAP parcel in GeoJSON format.
406
+
407
+ Args:
408
+ cap_id (str): Unique identifier of the CAP parcel.
409
+
410
+ Returns:
411
+ str: GeoJSON string with spatial data for the CAP parcel.
412
+
413
+ Use this tool to obtain the geometry of a CAP parcel for mapping or spatial analysis.
414
+ """
415
+ return make_api_request(f"/geographical-references/cap-parcels/{cap_id}/geolocation.geojson")
416
+
417
+ @mcp.tool()
418
+ async def get_municipality_cadastre(municipality_id: str) -> str:
419
+ """
420
+ Retrieve cadastre data for a municipality in GeoJSON format.
421
+
422
+ Args:
423
+ municipality_id (str): Unique identifier of the municipality.
424
+
425
+ Returns:
426
+ str: GeoJSON string with cadastre data for the municipality.
427
+
428
+ Use this tool to obtain the spatial extent of a municipality's cadastre for mapping or GIS analysis.
429
+ """
430
+ return make_api_request(f"/geographical-references/municipalities/{municipality_id}/cadastre.geojson")
431
+
432
+ if __name__ == "__main__":
433
+ mcp.run(transport='stdio')
requirements.txt CHANGED
@@ -1 +1,6 @@
1
- gradio[mcp]
 
 
 
 
 
 
1
+ gradio[mcp]
2
+ anthropic
3
+ mcp
4
+ openai
5
+ mistralai==0.4.2
6
+ PyPDF2
tool_utils.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fonctions utilitaires
2
+ from typing import Any, Dict, List
3
+
4
+
5
+ def filter_tools_for_context(tools: List[Any], conversation_messages: List[Dict[str, Any]], keywords: List[str]) -> List[Any]:
6
+ """Filtrage simple des outils selon contexte récent et mots-clés."""
7
+ recent_text = " ".join(
8
+ msg["content"] if isinstance(msg.get("content"), str) else " ".join(
9
+ item.get("text", "") for item in msg.get("content", []) if isinstance(item, dict)
10
+ )
11
+ for msg in conversation_messages[-5:]
12
+ ).lower()
13
+
14
+ selected = []
15
+ for tool in tools:
16
+ name = (tool.get("name") if isinstance(tool, dict) else getattr(tool, "name", "")).lower()
17
+ desc = (tool.get("description") if isinstance(tool, dict) else getattr(tool, "description", "")).lower()
18
+ if not keywords or any(kw.lower() in (name + desc + recent_text) for kw in keywords):
19
+ selected.append(tool)
20
+ return selected
21
+
22
+
23
+ def summarize_latest_results(conversation_messages: List[Dict[str, Any]]) -> str:
24
+ """Résumé des derniers résultats outils."""
25
+ summaries = []
26
+ for msg in conversation_messages:
27
+ if msg.get("role") == "user" and isinstance(msg.get("content"), list):
28
+ for item in msg["content"]:
29
+ if isinstance(item, dict) and item.get("type") == "tool_result":
30
+ summaries.append(item.get("content", ""))
31
+ return "\n".join(summaries[-5:]).strip()
32
+
33
+
34
+ def count_tokens(conversation_messages: List[Dict[str, Any]]) -> int:
35
+ """Comptage naïf de tokens (optimisation mémoire)."""
36
+ total = 0
37
+ for msg in conversation_messages:
38
+ content = msg.get("content")
39
+ if isinstance(content, str):
40
+ total += len(content.split())
41
+ elif isinstance(content, list):
42
+ for item in content:
43
+ if isinstance(item, dict) and "text" in item:
44
+ total += len(item["text"].split())
45
+ elif isinstance(item, str):
46
+ total += len(item.split())
47
+ return total
48
+
49
+
50
+ def trim_conversation(conversation_messages: List[Dict[str, Any]], keep_last_n: int = 5) -> List[Dict[str, Any]]:
51
+ """Réduction du contexte conversationnel."""
52
+ if len(conversation_messages) <= keep_last_n:
53
+ return conversation_messages
54
+ trimmed = conversation_messages[-keep_last_n:]
55
+ trimmed.insert(0, {"role": "system", "content": "Résumé des messages précédents supprimés pour raison de contexte."})
56
+ return trimmed