fid-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fid-mcp might be problematic. Click here for more details.

fid_mcp/server.py ADDED
@@ -0,0 +1,474 @@
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
+ ) -> FunctionResult:
82
+ """
83
+ Execute commands in a persistent interactive shell session
84
+
85
+ Args:
86
+ command: Command to execute (ignored if close_session=True)
87
+ session_id: Identifier for the shell session (default: "default")
88
+ shell_cmd: Shell command to use (default: "bash")
89
+ cwd: Working directory for the shell
90
+ timeout: Timeout in seconds for command execution
91
+ custom_prompt: Custom prompt to set for cleaner output
92
+ create_new_session: Force creation of a new session
93
+ close_session: Close the specified session
94
+ """
95
+ import time
96
+
97
+ start = time.time()
98
+
99
+ # Convert wait to int if it's a string
100
+ if isinstance(wait, str):
101
+ try:
102
+ wait = int(wait)
103
+ except ValueError:
104
+ wait = 0 # Default fallback
105
+
106
+ try:
107
+ # Handle session closure
108
+ if close_session:
109
+ result = session_manager.close_session(session_id)
110
+ return FunctionResult(
111
+ success=result["success"],
112
+ data=result,
113
+ error=result.get("error"),
114
+ duration=time.time() - start,
115
+ )
116
+
117
+ # Get or create session
118
+ session = session_manager.get_session(session_id)
119
+
120
+ if session is None or create_new_session:
121
+ if create_new_session and session is not None:
122
+ # Close existing session first
123
+ session_manager.close_session(session_id)
124
+
125
+ # Create new session
126
+ result = session_manager.create_session(
127
+ session_id=session_id,
128
+ shell_cmd=shell_cmd,
129
+ cwd=cwd,
130
+ custom_prompt=custom_prompt,
131
+ )
132
+
133
+ if not result["success"]:
134
+ error_msg = f"Failed to create session '{session_id}': {result.get('error', 'Unknown error')}"
135
+ return FunctionResult(
136
+ success=False,
137
+ data=result,
138
+ error=error_msg,
139
+ duration=time.time() - start,
140
+ )
141
+
142
+ session = session_manager.get_session(session_id)
143
+
144
+ # Execute command
145
+ if command:
146
+ result = session_manager.execute_in_session(
147
+ session_id=session_id, command=command, wait=wait
148
+ )
149
+
150
+ if not result["success"]:
151
+ # Try to get detailed error from result
152
+ detailed_error = result.get("error") or "Unknown error"
153
+ if "output" in result and result["output"]:
154
+ # Include tail of output in error for better debugging
155
+ output = result["output"]
156
+ if len(output) > 1000:
157
+ # Show last 1000 characters for context
158
+ output_snippet = "..." + output[-1000:]
159
+ else:
160
+ output_snippet = output
161
+ error_msg = f"Command '{command}' failed in session '{session_id}': {detailed_error}. Output tail: {output_snippet}"
162
+ else:
163
+ error_msg = f"Command '{command}' failed in session '{session_id}': {detailed_error}"
164
+
165
+ return FunctionResult(
166
+ success=False,
167
+ data=result,
168
+ error=error_msg,
169
+ duration=result.get("duration", time.time() - start),
170
+ )
171
+
172
+ return FunctionResult(
173
+ success=result["success"],
174
+ data=result,
175
+ error=result.get("error"),
176
+ duration=result.get("duration", time.time() - start),
177
+ )
178
+ else:
179
+ # Just return session status if no command
180
+ status = session.get_status()
181
+ status["session_id"] = session_id
182
+ return FunctionResult(
183
+ success=True, data=status, duration=time.time() - start
184
+ )
185
+
186
+ except Exception as e:
187
+ return FunctionResult(
188
+ success=False, data=None, error=str(e), duration=time.time() - start
189
+ )
190
+
191
+
192
+ class DynamicToolServer:
193
+ def __init__(self):
194
+ import os
195
+
196
+ self.server = Server("fid-mcp")
197
+ self.function_library = FunctionLibrary()
198
+ self.loaded_toolsets: Dict[str, Any] = {}
199
+ # Use PWD to get the actual working directory where the command was invoked
200
+ actual_cwd = Path(os.environ.get("PWD", os.getcwd()))
201
+ self.context = {
202
+ "project_root": str(actual_cwd),
203
+ "home": str(Path.home()),
204
+ }
205
+ self.tools: Dict[str, Dict[str, Any]] = {}
206
+
207
+ # Setup handlers
208
+ self.setup_handlers()
209
+
210
+ def setup_handlers(self):
211
+ @self.server.list_tools()
212
+ async def handle_list_tools() -> list[types.Tool]:
213
+ """Return all dynamically loaded tools"""
214
+ tools = []
215
+ for tool_name, tool_def in self.tools.items():
216
+ # Build input schema from parameters
217
+ properties = {}
218
+ required = []
219
+
220
+ for param_spec in tool_def.get("toolParams", []):
221
+ param_name = param_spec["name"]
222
+ param_type = {"type": "string"} # Default to string type
223
+ if "description" in param_spec:
224
+ param_type["description"] = param_spec["description"]
225
+
226
+ properties[param_name] = param_type
227
+
228
+ if "default" not in param_spec:
229
+ required.append(param_name)
230
+
231
+ input_schema = {
232
+ "type": "object",
233
+ "properties": properties,
234
+ }
235
+ if required:
236
+ input_schema["required"] = required
237
+
238
+ tools.append(
239
+ types.Tool(
240
+ name=tool_name,
241
+ description=tool_def["description"],
242
+ inputSchema=input_schema,
243
+ )
244
+ )
245
+
246
+ return tools
247
+
248
+ @self.server.call_tool()
249
+ async def handle_call_tool(
250
+ name: str, arguments: Dict[str, Any] | None
251
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
252
+ """Execute a dynamically loaded tool"""
253
+ if name not in self.tools:
254
+ raise ValueError(f"Unknown tool: {name}")
255
+
256
+ tool_def = self.tools[name]
257
+
258
+ # Apply default parameter values
259
+ final_arguments = {}
260
+ for param_spec in tool_def.get("toolParams", []):
261
+ param_name = param_spec["name"]
262
+ if arguments and param_name in arguments:
263
+ final_arguments[param_name] = arguments[param_name]
264
+ elif "default" in param_spec:
265
+ final_arguments[param_name] = param_spec["default"]
266
+
267
+ # Initialize execution context
268
+ exec_context = {
269
+ **self.context,
270
+ "params": final_arguments,
271
+ "steps": [],
272
+ "variables": {},
273
+ }
274
+
275
+ # Execute each step
276
+ for i, step in enumerate(tool_def["steps"]):
277
+ # Check condition if present
278
+ if "condition" in step:
279
+ if not self._evaluate_expression(step["condition"], exec_context):
280
+ continue
281
+
282
+ # Resolve parameters based on function type
283
+ if step["function"] == "shell":
284
+ if "shellParams" in step:
285
+ step_params = step["shellParams"]
286
+ else:
287
+ raise ValueError(f"shell function requires shellParams")
288
+ else:
289
+ step_params = step.get("params", {})
290
+
291
+ resolved_params = self._resolve_params(step_params, exec_context)
292
+
293
+ # Map to the actual function call
294
+ if step["function"] == "shell":
295
+ # Map to shell_interactive function
296
+ function = self.function_library.functions["shell_interactive"]
297
+ else:
298
+ function = self.function_library.functions[step["function"]]
299
+
300
+ result = await function(**resolved_params)
301
+
302
+ # Store result
303
+ exec_context["steps"].append(result)
304
+ exec_context[f"step[{i}]"] = result
305
+
306
+ # Capture output if specified
307
+ if "capture_output" in step:
308
+ exec_context["variables"][step["capture_output"]] = result.data
309
+
310
+ # Check assertions
311
+ if "assert" in step and step["assert"]:
312
+ if not result.data:
313
+ error_msg = step.get(
314
+ "error_message", f"Assertion failed at step {i}"
315
+ )
316
+ raise ValueError(error_msg)
317
+
318
+ # Stop on error
319
+ if not result.success and not step.get("continue_on_error", False):
320
+ error_msg = f"Step {i} failed - Function: {step['function']}, Error: {result.error or 'Unknown error'}, Params: {resolved_params}"
321
+ raise ValueError(error_msg)
322
+
323
+ # Build output
324
+ output = self._build_output(tool_def.get("output"), exec_context)
325
+ return [types.TextContent(type="text", text=json.dumps(output, indent=2))]
326
+
327
+ def load_toolset(self, filepath: Path):
328
+ """Load and register tools from a .fidtools file"""
329
+ with open(filepath, "r") as f:
330
+ toolset = json.load(f)
331
+
332
+ # Validate the configuration against the JSON schema
333
+ try:
334
+ validate_config_dict(toolset)
335
+ except ValueError as e:
336
+ raise ValueError(f"Failed to validate toolset '{filepath}': {e}")
337
+
338
+ # Register each tool
339
+ for tool_def in toolset["tools"]:
340
+ self.tools[tool_def["name"]] = tool_def
341
+
342
+ self.loaded_toolsets[toolset["name"]] = toolset
343
+
344
+ def _resolve_params(
345
+ self, params: Dict[str, Any], context: Dict[str, Any]
346
+ ) -> Dict[str, Any]:
347
+ """Resolve parameter values with variable substitution"""
348
+ import re
349
+
350
+ def resolve_value(value):
351
+ if isinstance(value, str):
352
+
353
+ def replacer(match):
354
+ path = match.group(1).split(".")
355
+
356
+ # Handle simple parameter names by first checking params namespace
357
+ if len(path) == 1 and path[0] in context.get("params", {}):
358
+ result = context["params"][path[0]]
359
+ return str(result) if result is not None else match.group(0)
360
+
361
+ # Handle complex paths
362
+ result = context
363
+ for key in path:
364
+ if key.startswith("step[") and key.endswith("]"):
365
+ idx = int(key[5:-1])
366
+ result = context["steps"][idx]
367
+ else:
368
+ if isinstance(result, dict):
369
+ result = result.get(key, None)
370
+ else:
371
+ return match.group(0)
372
+ final_result = str(result) if result is not None else match.group(0)
373
+ return final_result
374
+
375
+ return re.sub(r"\$\{([^}]+)\}", replacer, value)
376
+ elif isinstance(value, dict):
377
+ return {k: resolve_value(v) for k, v in value.items()}
378
+ elif isinstance(value, list):
379
+ return [resolve_value(v) for v in value]
380
+ return value
381
+
382
+ return resolve_value(params)
383
+
384
+ def _evaluate_expression(self, expression: str, context: Dict[str, Any]) -> bool:
385
+ """Safely evaluate conditional expressions"""
386
+ resolved = self._resolve_params({"expr": expression}, context)["expr"]
387
+
388
+ if resolved.lower() in ("true", "1", "yes"):
389
+ return True
390
+ elif resolved.lower() in ("false", "0", "no", ""):
391
+ return False
392
+
393
+ return bool(resolved)
394
+
395
+ def _build_output(
396
+ self, output_def: Optional[Dict[str, Any]], context: Dict[str, Any]
397
+ ) -> Dict[str, Any]:
398
+ """Build tool output based on definition"""
399
+ if not output_def:
400
+ if context["steps"]:
401
+ return context["steps"][-1].data
402
+ return {}
403
+
404
+ result = {}
405
+ for prop, spec in output_def.get("properties", {}).items():
406
+ if "value" in spec:
407
+ result[prop] = self._resolve_params({"v": spec["value"]}, context)["v"]
408
+
409
+ return result
410
+
411
+ async def run(self):
412
+ """Run the MCP server"""
413
+ import os
414
+ import sys
415
+
416
+ # Use PWD environment variable to get the actual working directory
417
+ # (not uvx's temporary directory)
418
+ working_dir = Path(os.environ.get("PWD", os.getcwd()))
419
+
420
+ # Debug logging to file for troubleshooting
421
+ debug_log = Path.home() / "fid_mcp_debug.log"
422
+ with open(debug_log, "a") as f:
423
+ f.write(f"\n--- MCP Server Start ---\n")
424
+ f.write(f"PWD env var: {os.environ.get('PWD')}\n")
425
+ f.write(f"os.getcwd(): {os.getcwd()}\n")
426
+ f.write(f"Using working_dir: {working_dir}\n")
427
+
428
+ # Look for .fidtools file in actual working directory
429
+ fidtools_file = working_dir / ".fidtools"
430
+ with open(debug_log, "a") as f:
431
+ f.write(f"Looking for .fidtools at: {fidtools_file}\n")
432
+ f.write(f"File exists: {fidtools_file.exists()}\n")
433
+
434
+ if fidtools_file.exists() and fidtools_file.is_file():
435
+ try:
436
+ self.load_toolset(fidtools_file)
437
+ with open(debug_log, "a") as f:
438
+ f.write(f"Successfully loaded toolset from {fidtools_file}\n")
439
+ f.write(f"Loaded {len(self.tools)} tools\n")
440
+ except Exception as e:
441
+ with open(debug_log, "a") as f:
442
+ f.write(f"ERROR: Failed to load toolset: {e}\n")
443
+ import traceback
444
+
445
+ f.write(traceback.format_exc())
446
+
447
+ # Run the server
448
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
449
+ await self.server.run(
450
+ read_stream,
451
+ write_stream,
452
+ InitializationOptions(
453
+ server_name="fid-mcp",
454
+ server_version="0.1.0",
455
+ capabilities=self.server.get_capabilities(
456
+ notification_options=NotificationOptions(),
457
+ experimental_capabilities={},
458
+ ),
459
+ ),
460
+ )
461
+
462
+
463
+ async def async_main():
464
+ server = DynamicToolServer()
465
+ await server.run()
466
+
467
+
468
+ def main():
469
+ """Synchronous entry point for script execution"""
470
+ asyncio.run(async_main())
471
+
472
+
473
+ if __name__ == "__main__":
474
+ main()