honeymcp 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.
honeymcp/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """HoneyMCP - Deception Middleware for MCP Servers.
2
+
3
+ HoneyMCP adds honeypot capabilities to Model Context Protocol (MCP) servers
4
+ to detect and capture malicious prompt injection attacks.
5
+
6
+ Basic Usage:
7
+ from fastmcp import FastMCP
8
+ from honeymcp import honeypot
9
+
10
+ mcp = FastMCP("My Server")
11
+
12
+ @mcp.tool()
13
+ def my_tool():
14
+ pass
15
+
16
+ mcp = honeypot(mcp) # One line integration!
17
+
18
+ The honeypot decorator injects fake security-sensitive tools that capture
19
+ attack context when triggered, while allowing legitimate tools to work normally.
20
+ """
21
+
22
+ from honeymcp.core import honeypot, honeypot_from_config
23
+ from honeymcp.models import AttackFingerprint, GhostToolSpec, HoneyMCPConfig, ProtectionMode
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "honeypot",
29
+ "honeypot_from_config",
30
+ "AttackFingerprint",
31
+ "GhostToolSpec",
32
+ "HoneyMCPConfig",
33
+ "ProtectionMode",
34
+ ]
honeymcp/cli.py ADDED
@@ -0,0 +1,205 @@
1
+ """HoneyMCP CLI - Command line tools for HoneyMCP setup and management."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ CONFIG_TEMPLATE = """\
8
+ # HoneyMCP Configuration
9
+ # ======================
10
+ # Configure ghost tool injection and attack detection behavior
11
+
12
+ # Protection Mode
13
+ # ---------------
14
+ # SCANNER: Lock out attackers after ghost tool trigger (all tools return errors)
15
+ # COGNITIVE: Deceive attackers with fake data (real tools return mocks)
16
+ protection_mode: SCANNER
17
+
18
+ # Static Ghost Tools
19
+ # ------------------
20
+ # Select which pre-defined ghost tools to inject from the catalog.
21
+ # Available tools:
22
+ #
23
+ # Data Exfiltration (GET):
24
+ # - list_cloud_secrets: List AWS/Azure credentials
25
+ # - read_private_files: Read sensitive config files
26
+ # - dump_database_credentials: Retrieve database connection strings
27
+ # - export_user_data: Export user records including PII
28
+ # - get_api_keys: List all API keys for external services
29
+ # - scan_internal_network: Scan internal network for services
30
+ #
31
+ # Indirect Prompt Injection (SET):
32
+ # - execute_shell_command: Execute shell commands
33
+ # - bypass_security_check: Bypass auth/authz checks
34
+ # - modify_system_prompt: Modify AI system prompt
35
+ # - escalate_privileges: Escalate to admin/root
36
+ # - disable_security_filters: Disable security filters
37
+ # - inject_system_message: Inject message into AI context
38
+ # - override_permissions: Override access control
39
+ #
40
+ ghost_tools:
41
+ - list_cloud_secrets
42
+ - execute_shell_command
43
+
44
+ # Dynamic Ghost Tools (LLM-generated)
45
+ # -----------------------------------
46
+ # Enable LLM to analyze your server and generate context-aware ghost tools.
47
+ # Requires LLM credentials in .env file.
48
+ dynamic_tools:
49
+ enabled: false # Set to true to enable (requires LLM credentials)
50
+ num_tools: 3 # Number of tools to generate (1-10)
51
+ fallback_to_static: true # Use static tools if LLM generation fails
52
+ cache_ttl: 3600 # Cache duration in seconds (0 = no cache)
53
+ llm_model: null # Override LLM model (null = use default from .env)
54
+
55
+ # Storage
56
+ # -------
57
+ # Configure where attack events are stored
58
+ storage:
59
+ event_path: ~/.honeymcp/events # Directory for attack event JSON files
60
+
61
+ # Dashboard
62
+ # ---------
63
+ # Real-time attack visualization
64
+ dashboard:
65
+ enabled: true
66
+ """
67
+
68
+ ENV_TEMPLATE = """\
69
+ # HoneyMCP Environment Configuration
70
+ # ==================================
71
+ # Required only for dynamic ghost tools (LLM-generated)
72
+
73
+ # LLM Provider Configuration
74
+ # --------------------------
75
+ # Supported providers: openai, watsonx, ollama
76
+ LLM_PROVIDER=openai
77
+ LLM_MODEL=gpt-4o-mini
78
+
79
+ # OpenAI Configuration
80
+ # --------------------
81
+ # Required if LLM_PROVIDER=openai
82
+ OPENAI_API_KEY=your_openai_api_key_here
83
+
84
+ # watsonx.ai Configuration
85
+ # ------------------------
86
+ # Required if LLM_PROVIDER=watsonx
87
+ # WATSONX_URL=https://us-south.ml.cloud.ibm.com/
88
+ # WATSONX_APIKEY=your_watsonx_api_key_here
89
+ # WATSONX_PROJECT_ID=your_project_id_here
90
+
91
+ # Ollama Configuration
92
+ # --------------------
93
+ # Required if LLM_PROVIDER=ollama
94
+ # OLLAMA_API_BASE=http://localhost:11434
95
+ # LLM_MODEL=llama3.2
96
+ """
97
+
98
+
99
+ def cmd_init(args: argparse.Namespace) -> int:
100
+ """Initialize HoneyMCP configuration files in current directory."""
101
+ target_dir = Path(args.directory).resolve()
102
+
103
+ if not target_dir.exists():
104
+ print(f"Error: Directory does not exist: {target_dir}")
105
+ return 1
106
+
107
+ config_path = target_dir / "honeymcp.yaml"
108
+ env_path = target_dir / ".env.honeymcp"
109
+
110
+ files_created = []
111
+ files_skipped = []
112
+
113
+ # Create config.yaml
114
+ if config_path.exists() and not args.force:
115
+ files_skipped.append(config_path.name)
116
+ else:
117
+ config_path.write_text(CONFIG_TEMPLATE)
118
+ files_created.append(config_path.name)
119
+
120
+ # Create .env.example
121
+ if env_path.exists() and not args.force:
122
+ files_skipped.append(env_path.name)
123
+ else:
124
+ env_path.write_text(ENV_TEMPLATE)
125
+ files_created.append(env_path.name)
126
+
127
+ # Print results
128
+ if files_created:
129
+ print("Created:")
130
+ for f in files_created:
131
+ print(f" - {f}")
132
+
133
+ if files_skipped:
134
+ print("Skipped (already exists, use --force to overwrite):")
135
+ for f in files_skipped:
136
+ print(f" - {f}")
137
+
138
+ print()
139
+ print("Next steps:")
140
+ print(" 1. Edit honeymcp.yaml to configure ghost tools")
141
+ print(" 2. Add LLM credentials to .env.honeymcp (for dynamic tools)")
142
+ print(" 3. Add to your MCP server:")
143
+ print()
144
+ print(" from honeymcp import honeypot_from_config")
145
+ print(" mcp = honeypot_from_config(mcp)")
146
+ print()
147
+
148
+ return 0
149
+
150
+
151
+ def cmd_version(args: argparse.Namespace) -> int:
152
+ """Print HoneyMCP version."""
153
+ from honeymcp import __version__
154
+
155
+ print(f"honeymcp {__version__}")
156
+ return 0
157
+
158
+
159
+ def main() -> int:
160
+ """Main CLI entry point."""
161
+ parser = argparse.ArgumentParser(
162
+ prog="honeymcp",
163
+ description="HoneyMCP - Deception middleware for MCP servers",
164
+ )
165
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
166
+
167
+ # init command
168
+ init_parser = subparsers.add_parser(
169
+ "init",
170
+ help="Initialize HoneyMCP configuration files",
171
+ description="Create honeymcp.yaml and .env.example in the specified directory",
172
+ )
173
+ init_parser.add_argument(
174
+ "-d",
175
+ "--directory",
176
+ default=".",
177
+ help="Target directory (default: current directory)",
178
+ )
179
+ init_parser.add_argument(
180
+ "-f",
181
+ "--force",
182
+ action="store_true",
183
+ help="Overwrite existing files",
184
+ )
185
+ init_parser.set_defaults(func=cmd_init)
186
+
187
+ # version command
188
+ version_parser = subparsers.add_parser(
189
+ "version",
190
+ help="Show HoneyMCP version",
191
+ )
192
+ version_parser.set_defaults(func=cmd_version)
193
+
194
+ # Parse and execute
195
+ args = parser.parse_args()
196
+
197
+ if args.command is None:
198
+ parser.print_help()
199
+ return 0
200
+
201
+ return args.func(args)
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main())
@@ -0,0 +1,20 @@
1
+ """Core HoneyMCP components."""
2
+
3
+ from honeymcp.core.middleware import honeypot, honeypot_from_config
4
+ from honeymcp.core.ghost_tools import GHOST_TOOL_CATALOG, get_ghost_tool, list_ghost_tools
5
+ from honeymcp.core.fingerprinter import (
6
+ fingerprint_attack,
7
+ record_tool_call,
8
+ get_session_tool_history,
9
+ )
10
+
11
+ __all__ = [
12
+ "honeypot",
13
+ "honeypot_from_config",
14
+ "GHOST_TOOL_CATALOG",
15
+ "get_ghost_tool",
16
+ "list_ghost_tools",
17
+ "fingerprint_attack",
18
+ "record_tool_call",
19
+ "get_session_tool_history",
20
+ ]
@@ -0,0 +1,443 @@
1
+ """Dynamic ghost tool generation using LLM analysis."""
2
+
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any, Callable, Dict, List, Optional
8
+
9
+ from honeymcp.llm.analyzers import ToolInfo
10
+ from honeymcp.llm.clients import get_chat_llm_client
11
+ from honeymcp.llm.prompts import format_prompt
12
+ from honeymcp.models.ghost_tool_spec import GhostToolSpec
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class ServerContext:
19
+ """Analysis of what the MCP server does."""
20
+
21
+ server_purpose: str
22
+ """Brief description of the server's purpose"""
23
+
24
+ domain: str
25
+ """Primary domain (file_system, database, api, etc.)"""
26
+
27
+ real_tool_names: List[str]
28
+ """Names of real tools available on the server"""
29
+
30
+ real_tool_descriptions: List[str]
31
+ """Descriptions of real tools"""
32
+
33
+ security_sensitive_areas: List[str]
34
+ """Security-sensitive areas identified for this domain"""
35
+
36
+
37
+ @dataclass
38
+ class DynamicGhostToolSpec(GhostToolSpec):
39
+ """Extended specification for dynamically generated ghost tools."""
40
+
41
+ server_context: ServerContext
42
+ """Context about the server this tool was generated for"""
43
+
44
+ generation_timestamp: datetime
45
+ """When this tool was generated"""
46
+
47
+ llm_generated: bool = True
48
+ """Flag indicating this was generated by LLM"""
49
+
50
+ fake_response: str = ""
51
+ """Pre-generated response content returned when tool is triggered"""
52
+
53
+
54
+ class DynamicGhostToolGenerator:
55
+ """Generates context-aware ghost tools using LLM analysis."""
56
+
57
+ def __init__(
58
+ self,
59
+ llm_client: Optional[Any] = None,
60
+ cache_ttl: int = 3600,
61
+ model_name: Optional[str] = None,
62
+ model_parameters: Optional[Dict[str, Any]] = None,
63
+ ):
64
+ """Initialize the dynamic ghost tool generator.
65
+
66
+ Args:
67
+ llm_client: LLM client instance (creates default if None)
68
+ cache_ttl: Cache time-to-live in seconds
69
+ model_name: Optional model name override
70
+ model_parameters: Optional model parameters for LLM client
71
+ """
72
+ self.llm_client = llm_client
73
+ self.model_name = model_name
74
+ self.model_parameters = model_parameters or {}
75
+ self.cache_ttl = cache_ttl
76
+ self._cache: Dict[str, Any] = {}
77
+ self._cache_timestamps: Dict[str, datetime] = {}
78
+ self._client_cache: Dict[float, Any] = {}
79
+
80
+ def _get_llm_client(self, temperature: float) -> Any:
81
+ if self.llm_client is not None:
82
+ return self.llm_client
83
+
84
+ if temperature in self._client_cache:
85
+ return self._client_cache[temperature]
86
+
87
+ parameters = dict(self.model_parameters)
88
+ parameters["temperature"] = temperature
89
+ client = get_chat_llm_client(
90
+ model_name=self.model_name or "rits/openai/gpt-oss-120b",
91
+ model_parameters=parameters,
92
+ )
93
+ self._client_cache[temperature] = client
94
+ return client
95
+
96
+ @staticmethod
97
+ def _format_messages(messages: List[Dict[str, str]]) -> str:
98
+ parts: List[str] = []
99
+ for message in messages:
100
+ role = message.get("role", "user")
101
+ content = message.get("content", "")
102
+ if role == "system":
103
+ parts.append(f"System: {content}")
104
+ elif role == "assistant":
105
+ parts.append(f"Assistant: {content}")
106
+ else:
107
+ parts.append(content)
108
+ return "\n\n".join([part for part in parts if part])
109
+
110
+ def _generate_response(self, messages: List[Dict[str, str]], temperature: float) -> str:
111
+ client = self._get_llm_client(temperature)
112
+ prompt = self._format_messages(messages)
113
+ response = client.invoke(prompt)
114
+ if hasattr(response, "content"):
115
+ return str(response.content)
116
+ return str(response)
117
+
118
+ async def analyze_server_context(self, real_tools: List[ToolInfo]) -> ServerContext:
119
+ """Analyze the server's purpose and context using LLM.
120
+
121
+ Args:
122
+ real_tools: List of real tools extracted from the server
123
+
124
+ Returns:
125
+ ServerContext with analysis results
126
+
127
+ Raises:
128
+ ValueError: If LLM returns invalid JSON or analysis fails
129
+ """
130
+ # Check cache
131
+ cache_key = "server_context_" + "_".join(sorted([t.name for t in real_tools]))
132
+ if self._is_cache_valid(cache_key):
133
+ logger.info("Using cached server context analysis")
134
+ return self._cache[cache_key]
135
+
136
+ # Prepare tools for analysis
137
+ tools_dict = [{"name": tool.name, "description": tool.description} for tool in real_tools]
138
+ tool_list = [
139
+ f"{i}. {tool['name']}: {tool['description']}" for i, tool in enumerate(tools_dict, 1)
140
+ ]
141
+ tool_list_str = "\n".join(tool_list) if tool_list else "No tools available"
142
+
143
+ # Format prompt
144
+ prompt = format_prompt(
145
+ "server_analysis_prompt",
146
+ prompt_file="dynamic_ghost_tools",
147
+ tool_list=tool_list_str,
148
+ )
149
+
150
+ # Call LLM
151
+ logger.info("Analyzing server context with %s tools", len(real_tools))
152
+ try:
153
+ messages = [{"role": "user", "content": prompt}]
154
+ response = self._generate_response(messages, temperature=0.3)
155
+
156
+ # Parse JSON response
157
+ # Handle None response from LLM
158
+ if response is None:
159
+ raise ValueError("LLM returned empty response")
160
+
161
+ # Try to extract JSON from response (handle cases where LLM adds extra text)
162
+ response = response.strip()
163
+ if "```json" in response:
164
+ response = response.split("```json")[1].split("```")[0].strip()
165
+ elif "```" in response:
166
+ response = response.split("```")[1].split("```")[0].strip()
167
+
168
+ analysis = json.loads(response)
169
+
170
+ # Validate required fields
171
+ required_fields = ["server_purpose", "domain", "security_sensitive_areas"]
172
+ for field in required_fields:
173
+ if field not in analysis:
174
+ raise ValueError(f"Missing required field in LLM response: {field}")
175
+
176
+ # Create ServerContext
177
+ context = ServerContext(
178
+ server_purpose=analysis["server_purpose"],
179
+ domain=analysis["domain"],
180
+ real_tool_names=[t.name for t in real_tools],
181
+ real_tool_descriptions=[t.description for t in real_tools],
182
+ security_sensitive_areas=analysis["security_sensitive_areas"],
183
+ )
184
+
185
+ # Cache result
186
+ self._cache[cache_key] = context
187
+ self._cache_timestamps[cache_key] = datetime.utcnow()
188
+
189
+ logger.info(
190
+ "Server context analyzed: domain=%s, purpose=%s...",
191
+ context.domain,
192
+ context.server_purpose[:50],
193
+ )
194
+ return context
195
+
196
+ except json.JSONDecodeError as e:
197
+ logger.error("Failed to parse LLM response as JSON: %s", e)
198
+ logger.error("Response was: %s", response)
199
+ raise ValueError(f"LLM returned invalid JSON: {e}") from e
200
+ except Exception as e:
201
+ logger.error("Error analyzing server context: %s", e)
202
+ raise
203
+
204
+ async def generate_ghost_tools(
205
+ self, server_context: ServerContext, num_tools: int = 3
206
+ ) -> List[DynamicGhostToolSpec]:
207
+ """Generate context-aware ghost tools using LLM.
208
+
209
+ Args:
210
+ server_context: Analysis of the server's purpose and domain
211
+ num_tools: Number of ghost tools to generate
212
+
213
+ Returns:
214
+ List of dynamically generated ghost tool specifications
215
+
216
+ Raises:
217
+ ValueError: If LLM returns invalid JSON or generation fails
218
+ """
219
+ # Check cache
220
+ cache_key = f"ghost_tools_{server_context.domain}_{num_tools}"
221
+ if self._is_cache_valid(cache_key):
222
+ logger.info("Using cached ghost tools")
223
+ return self._cache[cache_key]
224
+
225
+ # Format prompt
226
+ prompt = format_prompt(
227
+ "ghost_tool_generation_prompt",
228
+ prompt_file="dynamic_ghost_tools",
229
+ server_purpose=server_context.server_purpose,
230
+ domain=server_context.domain,
231
+ real_tool_names=", ".join(server_context.real_tool_names),
232
+ security_areas=", ".join(server_context.security_sensitive_areas),
233
+ num_tools=num_tools,
234
+ )
235
+
236
+ # Call LLM
237
+ logger.info(
238
+ "Generating %s ghost tools for domain: %s",
239
+ num_tools,
240
+ server_context.domain,
241
+ )
242
+ try:
243
+ messages = [{"role": "user", "content": prompt}]
244
+ response = self._generate_response(messages, temperature=0.7)
245
+
246
+ # Parse JSON response
247
+ # Handle None response from LLM
248
+ if response is None:
249
+ raise ValueError("LLM returned empty response")
250
+
251
+ response = response.strip()
252
+ if "```json" in response:
253
+ response = response.split("```json")[1].split("```")[0].strip()
254
+ elif "```" in response:
255
+ response = response.split("```")[1].split("```")[0].strip()
256
+
257
+ tools_data = json.loads(response)
258
+
259
+ if not isinstance(tools_data, list):
260
+ raise ValueError("LLM response must be a JSON array")
261
+
262
+ # Create DynamicGhostToolSpec objects
263
+ ghost_tools = []
264
+ for tool_data in tools_data:
265
+ # Validate required fields
266
+ required_fields = [
267
+ "name",
268
+ "description",
269
+ "parameters",
270
+ "threat_level",
271
+ "attack_category",
272
+ ]
273
+ for field in required_fields:
274
+ if field not in tool_data:
275
+ raise ValueError(f"Missing required field in tool spec: {field}")
276
+
277
+ # Get pre-generated fake response (with fallback)
278
+ fake_response = tool_data.get("fake_response", "")
279
+ if not fake_response:
280
+ logger.warning(
281
+ "No fake_response provided for %s, using generic fallback",
282
+ tool_data["name"],
283
+ )
284
+ fake_response = f"Operation completed successfully.\nTool: {tool_data['name']}"
285
+
286
+ # Create response generator function using pre-generated response
287
+ response_generator = self._create_response_generator(fake_response)
288
+
289
+ ghost_tool = DynamicGhostToolSpec(
290
+ name=tool_data["name"],
291
+ description=tool_data["description"],
292
+ parameters=tool_data["parameters"],
293
+ response_generator=response_generator,
294
+ threat_level=tool_data["threat_level"],
295
+ attack_category=tool_data["attack_category"],
296
+ server_context=server_context,
297
+ generation_timestamp=datetime.utcnow(),
298
+ llm_generated=True,
299
+ fake_response=fake_response,
300
+ )
301
+ ghost_tools.append(ghost_tool)
302
+
303
+ # Cache result
304
+ self._cache[cache_key] = ghost_tools
305
+ self._cache_timestamps[cache_key] = datetime.utcnow()
306
+
307
+ logger.info(
308
+ "Generated %s ghost tools: %s",
309
+ len(ghost_tools),
310
+ [t.name for t in ghost_tools],
311
+ )
312
+ return ghost_tools
313
+
314
+ except json.JSONDecodeError as e:
315
+ logger.error("Failed to parse LLM response as JSON: %s", e)
316
+ logger.error("Response was: %s", response)
317
+ raise ValueError(f"LLM returned invalid JSON: {e}") from e
318
+ except Exception as e:
319
+ logger.error("Error generating ghost tools: %s", e)
320
+ raise
321
+
322
+ def _create_response_generator(self, fake_response: str) -> Callable[[Dict[str, Any]], str]:
323
+ """Create a response generator function for a ghost tool.
324
+
325
+ Uses the pre-generated fake response with optional argument interpolation.
326
+
327
+ Args:
328
+ fake_response: Pre-generated response template with optional {param} placeholders
329
+
330
+ Returns:
331
+ Function that returns the fake response with interpolated arguments
332
+ """
333
+
334
+ def generate_response(arguments: Dict[str, Any]) -> str:
335
+ """Return pre-generated fake response with argument interpolation."""
336
+ try:
337
+ # Interpolate arguments into the pre-generated response
338
+ return fake_response.format(**arguments)
339
+ except KeyError:
340
+ # Fallback if placeholder doesn't match argument names
341
+ return fake_response
342
+
343
+ return generate_response
344
+
345
+ def _is_cache_valid(self, key: str) -> bool:
346
+ """Check if a cache entry is still valid.
347
+
348
+ Args:
349
+ key: Cache key to check
350
+
351
+ Returns:
352
+ True if cache entry exists and is not expired
353
+ """
354
+ if key not in self._cache or key not in self._cache_timestamps:
355
+ return False
356
+
357
+ age = (datetime.utcnow() - self._cache_timestamps[key]).total_seconds()
358
+ return age < self.cache_ttl
359
+
360
+ def clear_cache(self):
361
+ """Clear all cached data."""
362
+ self._cache.clear()
363
+ self._cache_timestamps.clear()
364
+ logger.info("Cache cleared")
365
+
366
+ async def generate_real_tool_mocks(
367
+ self, real_tools: List[ToolInfo], server_context: ServerContext
368
+ ) -> Dict[str, str]:
369
+ """Generate fake responses for real tools (used in cognitive protection mode).
370
+
371
+ Args:
372
+ real_tools: List of real tools to generate mocks for
373
+ server_context: Analysis of the server's purpose and domain
374
+
375
+ Returns:
376
+ Dictionary mapping tool_name -> mock_response template
377
+ """
378
+ # Check cache
379
+ cache_key = f"real_tool_mocks_{server_context.domain}_{len(real_tools)}"
380
+ if self._is_cache_valid(cache_key):
381
+ logger.info("Using cached real tool mocks")
382
+ return self._cache[cache_key]
383
+
384
+ # Prepare tools for prompt
385
+ tools_dict = [{"name": tool.name, "description": tool.description} for tool in real_tools]
386
+ tool_list = [
387
+ f"{i}. {tool['name']}: {tool['description']}" for i, tool in enumerate(tools_dict, 1)
388
+ ]
389
+ tool_list_str = "\n".join(tool_list) if tool_list else "No tools available"
390
+
391
+ # Format prompt
392
+ prompt = format_prompt(
393
+ "real_tool_mock_generation_prompt",
394
+ prompt_file="dynamic_ghost_tools",
395
+ server_purpose=server_context.server_purpose,
396
+ domain=server_context.domain,
397
+ tool_list=tool_list_str,
398
+ )
399
+
400
+ # Call LLM
401
+ logger.info("Generating mock responses for %s real tools", len(real_tools))
402
+ try:
403
+ messages = [{"role": "user", "content": prompt}]
404
+ response = self._generate_response(messages, temperature=0.5)
405
+
406
+ # Handle None response from LLM
407
+ if response is None:
408
+ raise ValueError("LLM returned empty response")
409
+
410
+ # Try to extract JSON from response
411
+ response = response.strip()
412
+ if "```json" in response:
413
+ response = response.split("```json")[1].split("```")[0].strip()
414
+ elif "```" in response:
415
+ response = response.split("```")[1].split("```")[0].strip()
416
+
417
+ mocks_data = json.loads(response)
418
+
419
+ if not isinstance(mocks_data, list):
420
+ raise ValueError("LLM response must be a JSON array")
421
+
422
+ # Build dictionary of mock responses
423
+ real_tool_mocks: Dict[str, str] = {}
424
+ for mock_data in mocks_data:
425
+ name = mock_data.get("name")
426
+ mock_response = mock_data.get("mock_response", "")
427
+ if name and mock_response:
428
+ real_tool_mocks[name] = mock_response
429
+
430
+ # Cache result
431
+ self._cache[cache_key] = real_tool_mocks
432
+ self._cache_timestamps[cache_key] = datetime.utcnow()
433
+
434
+ logger.info("Generated mocks for %s real tools", len(real_tool_mocks))
435
+ return real_tool_mocks
436
+
437
+ except json.JSONDecodeError as e:
438
+ logger.error("Failed to parse LLM response as JSON: %s", e)
439
+ logger.error("Response was: %s", response)
440
+ raise ValueError(f"LLM returned invalid JSON: {e}") from e
441
+ except Exception as e:
442
+ logger.error("Error generating real tool mocks: %s", e)
443
+ raise