fid-mcp 0.1.5__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.
fid_mcp/server.py ADDED
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Dict, Any, List, Optional, Callable, Union
6
+ from dataclasses import dataclass
7
+
8
+ from mcp.server import Server, NotificationOptions
9
+ from mcp.server.models import InitializationOptions
10
+ import mcp.server.stdio
11
+ import mcp.types as types
12
+
13
+ from .shell import session_manager
14
+ from .config import validate_config_dict
15
+
16
+
17
+ @dataclass
18
+ class FunctionResult:
19
+ success: bool
20
+ data: Any
21
+ error: Optional[str] = None
22
+ duration: Optional[float] = None
23
+
24
+
25
+ class FunctionLibrary:
26
+ """Registry of available functions that tools can use"""
27
+
28
+ def __init__(self):
29
+ self.functions: Dict[str, Callable] = {}
30
+ self._load_core_functions()
31
+
32
+ def _load_core_functions(self):
33
+ """Load built-in function library"""
34
+ self.functions.update(
35
+ {
36
+ "shell_execute": self._shell_execute,
37
+ "shell_interactive": self._shell_interactive,
38
+ }
39
+ )
40
+
41
+ async def _shell_execute(
42
+ self, command: str, cwd: Optional[str] = None
43
+ ) -> FunctionResult:
44
+ """Execute shell command"""
45
+ import time
46
+
47
+ start = time.time()
48
+
49
+ try:
50
+ proc = await asyncio.create_subprocess_shell(
51
+ command,
52
+ stdout=asyncio.subprocess.PIPE,
53
+ stderr=asyncio.subprocess.PIPE,
54
+ cwd=cwd,
55
+ )
56
+ stdout, stderr = await proc.communicate()
57
+
58
+ return FunctionResult(
59
+ success=proc.returncode == 0,
60
+ data={
61
+ "stdout": stdout.decode(),
62
+ "stderr": stderr.decode(),
63
+ "exit_code": proc.returncode,
64
+ },
65
+ error=stderr.decode() if proc.returncode != 0 else None,
66
+ duration=time.time() - start,
67
+ )
68
+ except Exception as e:
69
+ return FunctionResult(success=False, data=None, error=str(e))
70
+
71
+ async def _shell_interactive(
72
+ self,
73
+ command: str,
74
+ session_id: str = "default",
75
+ shell_cmd: str = "bash",
76
+ cwd: Optional[str] = None,
77
+ wait: Union[int, str] = 0,
78
+ custom_prompt: str = "SHELL> ",
79
+ create_new_session: bool = False,
80
+ close_session: bool = False,
81
+ expect_patterns: Optional[List[str]] = None,
82
+ responses: Optional[List[str]] = None,
83
+ ) -> FunctionResult:
84
+ """
85
+ Execute commands in a persistent interactive shell session
86
+
87
+ Args:
88
+ command: Command to execute (ignored if close_session=True)
89
+ session_id: Identifier for the shell session (default: "default")
90
+ shell_cmd: Shell command to use (default: "bash")
91
+ cwd: Working directory for the shell
92
+ wait: Timeout in seconds for command execution
93
+ custom_prompt: Custom prompt to set for cleaner output
94
+ create_new_session: Force creation of a new session
95
+ close_session: Close the specified session
96
+ expect_patterns: Regex patterns to expect during command execution
97
+ responses: Responses to send when patterns are matched
98
+ """
99
+ import time
100
+
101
+ start = time.time()
102
+
103
+ # Convert wait to int if it's a string
104
+ if isinstance(wait, str):
105
+ try:
106
+ wait = int(wait)
107
+ except ValueError:
108
+ wait = 0 # Default fallback
109
+
110
+ try:
111
+ # Handle session closure
112
+ if close_session:
113
+ result = session_manager.close_session(session_id)
114
+ return FunctionResult(
115
+ success=result["success"],
116
+ data=result,
117
+ error=result.get("error"),
118
+ duration=time.time() - start,
119
+ )
120
+
121
+ # Get or create session
122
+ session = session_manager.get_session(session_id)
123
+
124
+ if session is None or create_new_session:
125
+ if create_new_session and session is not None:
126
+ # Close existing session first
127
+ session_manager.close_session(session_id)
128
+
129
+ # Create new session
130
+ result = session_manager.create_session(
131
+ session_id=session_id,
132
+ shell_cmd=shell_cmd,
133
+ cwd=cwd,
134
+ custom_prompt=custom_prompt,
135
+ )
136
+
137
+ if not result["success"]:
138
+ error_msg = f"Failed to create session '{session_id}': {result.get('error', 'Unknown error')}"
139
+ return FunctionResult(
140
+ success=False,
141
+ data=result,
142
+ error=error_msg,
143
+ duration=time.time() - start,
144
+ )
145
+
146
+ session = session_manager.get_session(session_id)
147
+
148
+ # Execute command
149
+ if command:
150
+ result = session_manager.execute_in_session(
151
+ session_id=session_id,
152
+ command=command,
153
+ wait=wait,
154
+ expect_patterns=expect_patterns,
155
+ responses=responses,
156
+ )
157
+
158
+ if not result["success"]:
159
+ # Try to get detailed error from result
160
+ detailed_error = result.get("error") or "Unknown error"
161
+ if "output" in result and result["output"]:
162
+ # Include tail of output in error for better debugging
163
+ output = result["output"]
164
+ if len(output) > 1000:
165
+ # Show last 1000 characters for context
166
+ output_snippet = "..." + output[-1000:]
167
+ else:
168
+ output_snippet = output
169
+ error_msg = f"Command '{command}' failed in session '{session_id}': {detailed_error}. Output tail: {output_snippet}"
170
+ else:
171
+ error_msg = f"Command '{command}' failed in session '{session_id}': {detailed_error}"
172
+
173
+ return FunctionResult(
174
+ success=False,
175
+ data=result,
176
+ error=error_msg,
177
+ duration=result.get("duration", time.time() - start),
178
+ )
179
+
180
+ return FunctionResult(
181
+ success=result["success"],
182
+ data=result,
183
+ error=result.get("error"),
184
+ duration=result.get("duration", time.time() - start),
185
+ )
186
+ else:
187
+ # Just return session status if no command
188
+ status = session.get_status()
189
+ status["session_id"] = session_id
190
+ return FunctionResult(
191
+ success=True, data=status, duration=time.time() - start
192
+ )
193
+
194
+ except Exception as e:
195
+ return FunctionResult(
196
+ success=False, data=None, error=str(e), duration=time.time() - start
197
+ )
198
+
199
+
200
+ class DynamicToolServer:
201
+ def __init__(self):
202
+ import os
203
+
204
+ self.server = Server("fid-mcp")
205
+ self.function_library = FunctionLibrary()
206
+ self.loaded_toolsets: Dict[str, Any] = {}
207
+ # Use PWD to get the actual working directory where the command was invoked
208
+ actual_cwd = Path(os.environ.get("PWD", os.getcwd()))
209
+ self.context = {
210
+ "project_root": str(actual_cwd),
211
+ "home": str(Path.home()),
212
+ }
213
+ self.tools: Dict[str, Dict[str, Any]] = {}
214
+
215
+ # Fid knowledge base configuration
216
+ self.fid_config = None
217
+ self.fid_pat = os.environ.get("FID_PAT")
218
+
219
+ # Setup handlers
220
+ self.setup_handlers()
221
+
222
+ def setup_handlers(self):
223
+ @self.server.list_tools()
224
+ async def handle_list_tools() -> list[types.Tool]:
225
+ """Return all dynamically loaded tools plus Fid search"""
226
+ tools = []
227
+
228
+ # Always add Fid search tool (will show error if not configured when called)
229
+ tools.append(
230
+ types.Tool(
231
+ name="search",
232
+ description="Search the user's Fid knowledge base, which typically contains datasheets, integration manuals, API references, and other technical documentation, particularly for hardware components. ALWAYS try searching Fid first if the user asks about a hardware component, datasheet, manual, or API spec.",
233
+ inputSchema={
234
+ "type": "object",
235
+ "properties": {
236
+ "query": {
237
+ "type": "string",
238
+ "description": "The search query string",
239
+ }
240
+ },
241
+ "required": ["query"],
242
+ },
243
+ )
244
+ )
245
+
246
+ # Add dynamically loaded tools
247
+ for tool_name, tool_def in self.tools.items():
248
+ # Build input schema from parameters
249
+ properties = {}
250
+ required = []
251
+
252
+ for param_spec in tool_def.get("toolParams", []):
253
+ param_name = param_spec["name"]
254
+ param_type = {"type": "string"} # Default to string type
255
+ if "description" in param_spec:
256
+ param_type["description"] = param_spec["description"]
257
+
258
+ properties[param_name] = param_type
259
+
260
+ if "default" not in param_spec:
261
+ required.append(param_name)
262
+
263
+ input_schema = {
264
+ "type": "object",
265
+ "properties": properties,
266
+ }
267
+ if required:
268
+ input_schema["required"] = required
269
+
270
+ tools.append(
271
+ types.Tool(
272
+ name=tool_name,
273
+ description=tool_def["description"],
274
+ inputSchema=input_schema,
275
+ )
276
+ )
277
+
278
+ return tools
279
+
280
+ @self.server.call_tool()
281
+ async def handle_call_tool(
282
+ name: str, arguments: Dict[str, Any] | None
283
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
284
+ """Execute a dynamically loaded tool or Fid search"""
285
+
286
+ # Handle Fid search tool
287
+ if name == "search":
288
+ if not self.fid_pat:
289
+ error_msg = "Personal Access Token (PAT) missing. Please install the Fid MCP server according to these instructions: https://docs.fidlabs.ai/en/latest/connecting-agents.html"
290
+ return [types.TextContent(type="text", text=f"Error: {error_msg}")]
291
+
292
+ if not self.fid_config:
293
+ error_msg = "No Fid toolkit found in current directory. Download an existing toolkit from your Fid project, or create a new one with these instructions: https://docs.fidlabs.ai/en/latest/toolkits.html"
294
+ return [types.TextContent(type="text", text=f"Error: {error_msg}")]
295
+
296
+ if not arguments or "query" not in arguments:
297
+ raise ValueError("Query parameter is required")
298
+
299
+ try:
300
+ results = await self._search_fid_knowledge(arguments["query"])
301
+ return [
302
+ types.TextContent(
303
+ type="text", text=json.dumps(results, indent=2)
304
+ )
305
+ ]
306
+ except Exception as e:
307
+ error_msg = f"Search failed: {str(e)}"
308
+ return [types.TextContent(type="text", text=f"Error: {error_msg}")]
309
+
310
+ # Handle dynamic tools
311
+ if name not in self.tools:
312
+ raise ValueError(f"Unknown tool: {name}")
313
+
314
+ tool_def = self.tools[name]
315
+
316
+ # Apply default parameter values
317
+ final_arguments = {}
318
+ for param_spec in tool_def.get("toolParams", []):
319
+ param_name = param_spec["name"]
320
+ if arguments and param_name in arguments:
321
+ final_arguments[param_name] = arguments[param_name]
322
+ elif "default" in param_spec:
323
+ final_arguments[param_name] = param_spec["default"]
324
+
325
+ # Initialize execution context
326
+ exec_context = {
327
+ **self.context,
328
+ "params": final_arguments,
329
+ "steps": [],
330
+ "variables": {},
331
+ }
332
+
333
+ # Execute each step
334
+ for i, step in enumerate(tool_def["steps"]):
335
+ # Check condition if present
336
+ if "condition" in step:
337
+ if not self._evaluate_expression(step["condition"], exec_context):
338
+ continue
339
+
340
+ # Resolve parameters based on function type
341
+ if step["function"] == "shell":
342
+ if "shellParams" in step:
343
+ step_params = step["shellParams"]
344
+ else:
345
+ raise ValueError(f"shell function requires shellParams")
346
+ else:
347
+ step_params = step.get("params", {})
348
+
349
+ resolved_params = self._resolve_params(step_params, exec_context)
350
+
351
+ # Map to the actual function call
352
+ if step["function"] == "shell":
353
+ # Map to shell_interactive function
354
+ function = self.function_library.functions["shell_interactive"]
355
+ else:
356
+ function = self.function_library.functions[step["function"]]
357
+
358
+ result = await function(**resolved_params)
359
+
360
+ # Store result
361
+ exec_context["steps"].append(result)
362
+ exec_context[f"step[{i}]"] = result
363
+
364
+ # Capture output if specified
365
+ if "capture_output" in step:
366
+ exec_context["variables"][step["capture_output"]] = result.data
367
+
368
+ # Check assertions
369
+ if "assert" in step and step["assert"]:
370
+ if not result.data:
371
+ error_msg = step.get(
372
+ "error_message", f"Assertion failed at step {i}"
373
+ )
374
+ raise ValueError(error_msg)
375
+
376
+ # Stop on error
377
+ if not result.success and not step.get("continue_on_error", False):
378
+ error_msg = f"Step {i} failed - Function: {step['function']}, Error: {result.error or 'Unknown error'}, Params: {resolved_params}"
379
+ raise ValueError(error_msg)
380
+
381
+ # Build output
382
+ output = self._build_output(tool_def.get("output"), exec_context)
383
+ return [types.TextContent(type="text", text=json.dumps(output, indent=2))]
384
+
385
+ async def _search_fid_knowledge(self, query: str) -> Dict[str, Any]:
386
+ """Search the Fid knowledge base"""
387
+ import aiohttp
388
+ import time
389
+
390
+ start_time = time.time()
391
+ default_k = 6
392
+
393
+ # Use the correct projects/{projectId}/search endpoint
394
+ url = f"{self.fid_config['apiBaseUrl']}/projects/{self.fid_config['projectId']}/search"
395
+ headers = {
396
+ "X-API-Key": self.fid_pat,
397
+ "X-Client-Source": "mcp_client",
398
+ }
399
+
400
+ payload = {
401
+ "query": query,
402
+ "k": default_k,
403
+ "userId": "", # Required field, can be empty
404
+ "search_method": "multi_stage",
405
+ "multi_stage_methods": ["full_text", "vector"],
406
+ }
407
+
408
+ async with aiohttp.ClientSession() as session:
409
+ async with session.post(url, json=payload, headers=headers) as response:
410
+ if response.status != 200:
411
+ error_text = await response.text()
412
+ raise ValueError(
413
+ f"API request failed with status {response.status}: {error_text}"
414
+ )
415
+
416
+ data = await response.json()
417
+
418
+ # Log search time
419
+ duration = time.time() - start_time
420
+
421
+ if not data.get("results"):
422
+ return {
423
+ "results": [],
424
+ "message": "No search results found",
425
+ "duration": duration,
426
+ }
427
+
428
+ return {
429
+ "results": data.get("results", []),
430
+ "totalCount": data.get("totalCount"),
431
+ "duration": duration,
432
+ }
433
+
434
+ def load_toolset(self, filepath: Path):
435
+ """Load and register tools from a .fidtools or fidtools.json file"""
436
+ with open(filepath, "r") as f:
437
+ toolset = json.load(f)
438
+
439
+ # Validate the configuration against the JSON schema
440
+ try:
441
+ validate_config_dict(toolset)
442
+ except ValueError as e:
443
+ raise ValueError(f"Failed to validate toolset '{filepath}': {e}")
444
+
445
+ # Extract Fid configuration if present
446
+ if "projectId" in toolset and "apiBaseUrl" in toolset:
447
+ self.fid_config = {
448
+ "projectId": toolset["projectId"],
449
+ "apiBaseUrl": toolset["apiBaseUrl"],
450
+ }
451
+
452
+ # Register each tool
453
+ for tool_def in toolset["tools"]:
454
+ self.tools[tool_def["name"]] = tool_def
455
+
456
+ self.loaded_toolsets[toolset["name"]] = toolset
457
+
458
+ def _resolve_params(
459
+ self, params: Dict[str, Any], context: Dict[str, Any]
460
+ ) -> Dict[str, Any]:
461
+ """Resolve parameter values with variable substitution"""
462
+ import re
463
+
464
+ def resolve_value(value):
465
+ if isinstance(value, str):
466
+
467
+ def replacer(match):
468
+ path = match.group(1).split(".")
469
+
470
+ # Handle simple parameter names by first checking params namespace
471
+ if len(path) == 1 and path[0] in context.get("params", {}):
472
+ result = context["params"][path[0]]
473
+ return str(result) if result is not None else match.group(0)
474
+
475
+ # Handle complex paths
476
+ result = context
477
+ for key in path:
478
+ if key.startswith("step[") and key.endswith("]"):
479
+ idx = int(key[5:-1])
480
+ result = context["steps"][idx]
481
+ else:
482
+ if isinstance(result, dict):
483
+ result = result.get(key, None)
484
+ else:
485
+ return match.group(0)
486
+ final_result = str(result) if result is not None else match.group(0)
487
+ return final_result
488
+
489
+ return re.sub(r"\$\{([^}]+)\}", replacer, value)
490
+ elif isinstance(value, dict):
491
+ return {k: resolve_value(v) for k, v in value.items()}
492
+ elif isinstance(value, list):
493
+ return [resolve_value(v) for v in value]
494
+ return value
495
+
496
+ return resolve_value(params)
497
+
498
+ def _evaluate_expression(self, expression: str, context: Dict[str, Any]) -> bool:
499
+ """Safely evaluate conditional expressions"""
500
+ resolved = self._resolve_params({"expr": expression}, context)["expr"]
501
+
502
+ if resolved.lower() in ("true", "1", "yes"):
503
+ return True
504
+ elif resolved.lower() in ("false", "0", "no", ""):
505
+ return False
506
+
507
+ return bool(resolved)
508
+
509
+ def _build_output(
510
+ self, output_def: Optional[Dict[str, Any]], context: Dict[str, Any]
511
+ ) -> Dict[str, Any]:
512
+ """Build tool output based on definition"""
513
+ if not output_def:
514
+ if context["steps"]:
515
+ # Return all steps' output for visibility
516
+ return {
517
+ "steps": [
518
+ {
519
+ "command": step.data.get("command", "") if step.data else "",
520
+ "success": step.success,
521
+ "output": step.data.get("output", "") if step.data else "",
522
+ "duration": step.duration,
523
+ }
524
+ for step in context["steps"]
525
+ ]
526
+ }
527
+ return {}
528
+
529
+ result = {}
530
+ for prop, spec in output_def.get("properties", {}).items():
531
+ if "value" in spec:
532
+ result[prop] = self._resolve_params({"v": spec["value"]}, context)["v"]
533
+
534
+ return result
535
+
536
+ async def run(self):
537
+ """Run the MCP server"""
538
+ import os
539
+ import sys
540
+
541
+ # Use PWD environment variable to get the actual working directory
542
+ # (not uvx's temporary directory)
543
+ working_dir = Path(os.environ.get("PWD", os.getcwd()))
544
+
545
+ # Debug logging to file for troubleshooting
546
+ debug_log = Path.home() / "fid_mcp_debug.log"
547
+ with open(debug_log, "a") as f:
548
+ f.write(f"\n--- MCP Server Start ---\n")
549
+ f.write(f"PWD env var: {os.environ.get('PWD')}\n")
550
+ f.write(f"os.getcwd(): {os.getcwd()}\n")
551
+ f.write(f"Using working_dir: {working_dir}\n")
552
+
553
+ # Look for .fidtools or fidtools.json file in actual working directory
554
+ config_files = [working_dir / ".fidtools", working_dir / "fidtools.json"]
555
+
556
+ config_file = None
557
+ for file_path in config_files:
558
+ if file_path.exists() and file_path.is_file():
559
+ config_file = file_path
560
+ break
561
+
562
+ with open(debug_log, "a") as f:
563
+ f.write(
564
+ f"Looking for config files: {', '.join(str(f) for f in config_files)}\n"
565
+ )
566
+ if config_file:
567
+ f.write(f"Found config file: {config_file}\n")
568
+ else:
569
+ f.write("No config file found\n")
570
+
571
+ if config_file:
572
+ try:
573
+ self.load_toolset(config_file)
574
+ with open(debug_log, "a") as f:
575
+ f.write(f"Successfully loaded toolset from {config_file}\n")
576
+ f.write(f"Loaded {len(self.tools)} tools\n")
577
+ except Exception as e:
578
+ with open(debug_log, "a") as f:
579
+ f.write(f"ERROR: Failed to load toolset: {e}\n")
580
+ import traceback
581
+
582
+ f.write(traceback.format_exc())
583
+
584
+ # Run the server
585
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
586
+ await self.server.run(
587
+ read_stream,
588
+ write_stream,
589
+ InitializationOptions(
590
+ server_name="fid-mcp",
591
+ server_version="0.1.0",
592
+ capabilities=self.server.get_capabilities(
593
+ notification_options=NotificationOptions(),
594
+ experimental_capabilities={},
595
+ ),
596
+ ),
597
+ )
598
+
599
+
600
+ async def async_main():
601
+ server = DynamicToolServer()
602
+ await server.run()
603
+
604
+
605
+ def main():
606
+ """Synchronous entry point for script execution"""
607
+ asyncio.run(async_main())
608
+
609
+
610
+ if __name__ == "__main__":
611
+ main()