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/__init__.py +1 -0
- fid_mcp/config.py +243 -0
- fid_mcp/server.py +611 -0
- fid_mcp/shell.py +883 -0
- fid_mcp-0.1.5.dist-info/METADATA +29 -0
- fid_mcp-0.1.5.dist-info/RECORD +9 -0
- fid_mcp-0.1.5.dist-info/WHEEL +4 -0
- fid_mcp-0.1.5.dist-info/entry_points.txt +2 -0
- fid_mcp-0.1.5.dist-info/licenses/LICENSE +6 -0
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()
|