Skip to content

Commit 18fe810

Browse files
committed
feat: enhance discover_tools security and robustness
- Add comprehensive input sanitization for LLM task descriptions - Remove control characters and normalize whitespace - Detect and filter potential prompt injection patterns - Enforce length limits (2000 chars) for safety - Validate non-empty strings after sanitization - Enhance null safety checks throughout response processing - Add comprehensive validation for sampling results - Improve error handling for malformed responses - Validate content structure before processing - Handle both array and object content formats safely - Make LLM parameters configurable via environment variables - XCODEBUILDMCP_LLM_MAX_TOKENS (default: 200) - XCODEBUILDMCP_LLM_TEMPERATURE (optional) - Replace hardcoded maxTokens with configurable values - Maintain backward compatibility with existing functionality - Add explicit ESLint disable for security-critical regex pattern - All changes validated through comprehensive manual testing Addresses remaining GitHub PR security review comments.
1 parent 4ad9363 commit 18fe810

1 file changed

Lines changed: 161 additions & 30 deletions

File tree

src/mcp/tools/discovery/discover_tools.ts

Lines changed: 161 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,69 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1414

1515
// Using McpServer type from SDK instead of custom interface
1616

17+
// Configuration for LLM parameters - made configurable instead of hardcoded
18+
interface LLMConfig {
19+
maxTokens: number;
20+
temperature?: number;
21+
}
22+
23+
// Default LLM configuration with environment variable overrides
24+
const getLLMConfig = (): LLMConfig => ({
25+
maxTokens: process.env.XCODEBUILDMCP_LLM_MAX_TOKENS
26+
? parseInt(process.env.XCODEBUILDMCP_LLM_MAX_TOKENS, 10)
27+
: 200,
28+
temperature: process.env.XCODEBUILDMCP_LLM_TEMPERATURE
29+
? parseFloat(process.env.XCODEBUILDMCP_LLM_TEMPERATURE)
30+
: undefined,
31+
});
32+
33+
/**
34+
* Sanitizes user input to prevent injection attacks and ensure safe LLM usage
35+
* @param input The raw user input to sanitize
36+
* @returns Sanitized input safe for LLM processing
37+
*/
38+
function sanitizeTaskDescription(input: string): string {
39+
if (!input || typeof input !== 'string') {
40+
throw new Error('Task description must be a non-empty string');
41+
}
42+
43+
// Remove control characters and normalize whitespace
44+
let sanitized = input
45+
// eslint-disable-next-line no-control-regex -- Intentional control character removal for security
46+
.replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove control characters
47+
.replace(/\s+/g, ' ') // Normalize whitespace
48+
.trim();
49+
50+
// Length validation - prevent excessively long inputs
51+
if (sanitized.length === 0) {
52+
throw new Error('Task description cannot be empty after sanitization');
53+
}
54+
55+
if (sanitized.length > 2000) {
56+
sanitized = sanitized.substring(0, 2000);
57+
log('warn', 'Task description truncated to 2000 characters for safety');
58+
}
59+
60+
// Basic injection prevention - remove potential prompt injection patterns
61+
const suspiciousPatterns = [
62+
/ignore\s+previous\s+instructions/gi,
63+
/forget\s+everything/gi,
64+
/system\s*:/gi,
65+
/assistant\s*:/gi,
66+
/you\s+are\s+now/gi,
67+
/act\s+as/gi,
68+
];
69+
70+
for (const pattern of suspiciousPatterns) {
71+
if (pattern.test(sanitized)) {
72+
log('warn', 'Potentially suspicious pattern detected in task description');
73+
sanitized = sanitized.replace(pattern, '[filtered]');
74+
}
75+
}
76+
77+
return sanitized;
78+
}
79+
1780
// Define schema as ZodObject
1881
const discoverToolsSchema = z.object({
1982
task_description: z
@@ -47,8 +110,23 @@ export async function discover_toolsLogic(
47110
_executor?: unknown,
48111
deps?: Dependencies,
49112
): Promise<ToolResponse> {
113+
// Enhanced null safety checks
114+
if (!args || typeof args !== 'object') {
115+
return createTextResponse('Invalid arguments provided to discover_tools', true);
116+
}
117+
50118
const { task_description, additive } = args;
51-
log('info', `Discovering tools for task: ${task_description}`);
119+
120+
// Sanitize the task description to prevent injection attacks
121+
let sanitizedTaskDescription: string;
122+
try {
123+
sanitizedTaskDescription = sanitizeTaskDescription(task_description);
124+
log('info', `Discovering tools for task: ${sanitizedTaskDescription}`);
125+
} catch (error) {
126+
const errorMessage = error instanceof Error ? error.message : 'Invalid task description';
127+
log('error', `Task description sanitization failed: ${errorMessage}`);
128+
return createTextResponse(`Invalid task description: ${errorMessage}`, true);
129+
}
52130

53131
try {
54132
// Get the server instance from the global context
@@ -74,10 +152,10 @@ export async function discover_toolsLogic(
74152
deps?.generateWorkflowDescriptions ?? generateWorkflowDescriptions
75153
)();
76154

77-
// 3. Construct the prompt for the LLM
155+
// 3. Construct the prompt for the LLM using sanitized input
78156
const userPrompt = `You are an expert assistant for the XcodeBuildMCP server. Your task is to select the most relevant workflow for a user's Apple development request.
79157
80-
The user wants to perform the following task: "${task_description}"
158+
The user wants to perform the following task: "${sanitizedTaskDescription}"
81159
82160
IMPORTANT: Each workflow represents a complete end-to-end development workflow. Choose ONLY ONE workflow that best matches the user's project type and target platform:
83161
@@ -98,41 +176,79 @@ ${workflowDescriptions}
98176
Respond with ONLY a JSON array containing ONE workflow name that best matches the task (e.g., ["simulator-workspace"]).
99177
Each workflow contains ALL tools needed for its complete development workflow - no need to combine workflows.`;
100178

101-
// 4. Send sampling request
102-
log('debug', 'Sending sampling request to client LLM');
179+
// 4. Send sampling request with configurable parameters
180+
const llmConfig = getLLMConfig();
181+
log('debug', `Sending sampling request to client LLM with maxTokens: ${llmConfig.maxTokens}`);
103182
if (!server.server?.createMessage) {
104183
throw new Error('Server does not support message creation');
105184
}
106-
const samplingResult = await server.server.createMessage({
185+
186+
const samplingOptions: {
187+
messages: Array<{ role: 'user'; content: { type: 'text'; text: string } }>;
188+
maxTokens: number;
189+
temperature?: number;
190+
} = {
107191
messages: [{ role: 'user', content: { type: 'text', text: userPrompt } }],
108-
maxTokens: 200,
109-
});
192+
maxTokens: llmConfig.maxTokens,
193+
};
110194

111-
// 5. Parse the response
195+
// Only add temperature if configured
196+
if (llmConfig.temperature !== undefined) {
197+
samplingOptions.temperature = llmConfig.temperature;
198+
}
199+
200+
const samplingResult = await server.server.createMessage(samplingOptions);
201+
202+
// 5. Parse the response with enhanced null safety checks
112203
let selectedWorkflows: string[] = [];
113204
try {
205+
// Enhanced null safety - check if samplingResult exists and has expected structure
206+
if (!samplingResult || typeof samplingResult !== 'object') {
207+
throw new Error('Invalid sampling result: null or not an object');
208+
}
209+
114210
const content = (
115211
samplingResult as {
116-
content: Array<{ type: 'text'; text: string }> | { type: 'text'; text: string };
212+
content?: Array<{ type: 'text'; text: string }> | { type: 'text'; text: string } | null;
117213
}
118214
).content;
215+
216+
if (!content) {
217+
throw new Error('No content in sampling response');
218+
}
219+
119220
let responseText = '';
120221

121-
// Handle both array and single object content formats
122-
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
123-
responseText = content[0].text.trim();
222+
// Handle both array and single object content formats with enhanced null checks
223+
if (Array.isArray(content)) {
224+
if (content.length === 0) {
225+
throw new Error('Empty content array in sampling response');
226+
}
227+
const firstItem = content[0];
228+
if (!firstItem || typeof firstItem !== 'object' || firstItem.type !== 'text') {
229+
throw new Error('Invalid first content item in array');
230+
}
231+
if (!firstItem.text || typeof firstItem.text !== 'string') {
232+
throw new Error('Invalid text content in first array item');
233+
}
234+
responseText = firstItem.text.trim();
124235
} else if (
125236
content &&
126237
typeof content === 'object' &&
127238
'type' in content &&
128239
content.type === 'text' &&
129-
'text' in content
240+
'text' in content &&
241+
typeof content.text === 'string'
130242
) {
131-
responseText = (content.text as string).trim();
243+
responseText = content.text.trim();
132244
} else {
133245
throw new Error('Invalid content format in sampling response');
134246
}
135247

248+
if (!responseText) {
249+
throw new Error('Empty response text after parsing');
250+
}
251+
136252
log('debug', `LLM response: ${responseText}`);
137253

138254
const parsedResponse: unknown = JSON.parse(responseText);
@@ -161,24 +277,39 @@ Each workflow contains ALL tools needed for its complete development workflow -
161277
}
162278
} catch (error) {
163279
log('error', `Failed to parse LLM response: ${error}`);
164-
// Extract the response text for error reporting
280+
// Extract the response text for error reporting with enhanced null safety
165281
let errorResponseText = 'Unknown response format';
166282
try {
167-
const content = (
168-
samplingResult as {
169-
content: Array<{ type: 'text'; text: string }> | { type: 'text'; text: string };
283+
if (samplingResult && typeof samplingResult === 'object') {
284+
const content = (
285+
samplingResult as {
286+
content?:
287+
| Array<{ type: 'text'; text: string }>
288+
| { type: 'text'; text: string }
289+
| null;
290+
}
291+
).content;
292+
293+
if (content && Array.isArray(content) && content.length > 0) {
294+
const firstItem = content[0];
295+
if (
296+
firstItem &&
297+
typeof firstItem === 'object' &&
298+
firstItem.type === 'text' &&
299+
typeof firstItem.text === 'string'
300+
) {
301+
errorResponseText = firstItem.text;
302+
}
303+
} else if (
304+
content &&
305+
typeof content === 'object' &&
306+
'type' in content &&
307+
content.type === 'text' &&
308+
'text' in content &&
309+
typeof content.text === 'string'
310+
) {
311+
errorResponseText = content.text;
170312
}
171-
).content;
172-
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
173-
errorResponseText = content[0].text;
174-
} else if (
175-
content &&
176-
typeof content === 'object' &&
177-
'type' in content &&
178-
content.type === 'text' &&
179-
'text' in content
180-
) {
181-
errorResponseText = content.text as string;
182313
}
183314
} catch {
184315
// Keep default error message

0 commit comments

Comments
 (0)