Module 8: Security
Duration: ~25 minutes | Level: Intermediate | Prerequisites: Module 7: Testing
The AI Doesn't Write Safe Code By Default
Here's a sobering reality: when the AI constructs tool call arguments, it's trying to be helpful, not adversarial. But it doesn't know about your filesystem layout, your database schema, or which SQL queries are safe.
Treat every argument in every tool call as untrusted user input. Because in an agentic system, the "user" might be the AI, which might have been fed malicious instructions through prompt injection.
Path Traversal Prevention
Any tool that accesses the filesystem must prevent path traversal:
private static final Path SANDBOX = Path.of(System.getProperty("user.home"), "mcp-workspace");
private Path validatePath(String userPath) {
Path resolved = SANDBOX.resolve(userPath).normalize();
if (!resolved.startsWith(SANDBOX)) {
throw new SecurityException("Path escapes sandbox: " + userPath);
}
return resolved;
}
// In the tool handler:
(exchange, request) -> {
Map<String, Object> args = request.arguments();
Path target;
try {
target = validatePath((String) args.get("path"));
} catch (SecurityException e) {
return CallToolResult.builder().isError(true).addTextContent("Access denied: " + e.getMessage()).build();
}
// ... use target safely
}
The .normalize() call resolves .. components before the sandbox check. Without it, ../../../../etc/passwd would not be caught.
SQL Injection Prevention
Always use parameterised queries. Always.
// WRONG, SQL injection
String sql = "SELECT * FROM users WHERE email = '" + args.get("email") + "'";
// CORRECT, parameterised
String sql = "SELECT * FROM users WHERE email = ?";
try (var stmt = connection.prepareStatement(sql)) {
stmt.setString(1, (String) args.get("email"));
// ...
}
For tools that accept raw SQL (like a run_query tool for a read-only analytics server), enforce read-only mode at the database level by using a read-only database user. Never let the AI run arbitrary SQL as a write-capable user.
Command Injection Prevention
// WRONG, command injection
String cmd = "grep " + args.get("pattern") + " /var/log/app.log";
Runtime.getRuntime().exec(cmd); // this String overload is also deprecated since Java 18
// CORRECT, explicit argument array
ProcessBuilder pb = new ProcessBuilder(
"grep",
"--fixed-strings", // disable regex (safer)
(String) args.get("pattern"),
"/var/log/app.log"
);
pb.redirectErrorStream(true);
Avoid shell=true equivalents. When using ProcessBuilder, pass arguments as separate strings, not as a single concatenated command string.
Authentication for HTTP Servers
For production HTTP-based MCP servers, require authentication on every request:
// Spring Boot filter example
@Component
public class McpAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(401);
response.getWriter().write("{\"error\":\"Missing or invalid Authorization header\"}");
return;
}
String token = authHeader.substring(7);
if (!tokenValidator.isValid(token)) {
// An invalid/expired/malformed token is RFC 6750 "invalid_token" -> 401, not 403.
// (403 is reserved for a valid token that lacks the required scope/permissions.)
response.setStatus(401);
response.setHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
response.getWriter().write("{\"error\":\"invalid_token\"}");
return;
}
chain.doFilter(request, response);
}
}
For stdio servers, authentication isn't needed. The server runs as a child process of the user's application, inheriting OS-level access control.
Rate Limiting
Prevent the AI from accidentally calling expensive tools thousands of times:
// RateLimiter requires `com.google.guava:guava` as a dependency; not transitive from the MCP SDK
private final RateLimiter rateLimiter = RateLimiter.create(10.0); // 10 calls/sec
(exchange, request) -> {
if (!rateLimiter.tryAcquire()) {
return CallToolResult.builder().isError(true).addTextContent(
"Rate limit exceeded. Please wait before calling this tool again."
).build();
}
// ... proceed
}
Use per-session or per-user rate limiters for multi-user servers. Guava's RateLimiter works for single-server deployments; use Redis-backed rate limiting for distributed systems.
Prompt Injection Defence
When your tools return external content (web pages, user documents, database text fields), that content can contain instructions that hijack the AI:
// Naive, passes raw external content to AI
String externalContent = fetchExternalPage(url);
return CallToolResult.builder().addTextContent(externalContent).build();
// Better, wrap in structured JSON so AI treats it as data
String safeContent = objectMapper.writeValueAsString(Map.of(
"source", url,
"content", externalContent,
"retrieved_at", Instant.now().toString()
));
return CallToolResult.builder().addTextContent(safeContent).build();
// Best, sanitise before returning
String sanitised = sanitiser.removeInstructionPatterns(externalContent);
return CallToolResult.builder().addTextContent(sanitised).build();
Wrapping content in JSON is a simple first step: it labels the text as data from a specific source and makes provenance explicit. It is only a partial mitigation, though, since the model can still read instructions inside the string, so combine it with sanitisation and least-privilege tool design.
Key Takeaways
- Treat all tool arguments as untrusted input
- Path traversal prevention:
resolve().normalize()+ sandbox boundary check - SQL: parameterised queries always; read-only DB user for analytics tools
- Command execution:
ProcessBuilderwith explicit argument arrays, never shell concatenation - HTTP servers need authentication on every request
- Rate limit expensive tools to prevent runaway AI loops
- Wrap external content in JSON to defend against prompt injection
Companion code
The path-traversal pattern (resolve + normalize + startsWith boundary check) is implemented and unit-tested in projects/mcp-java-sdk/tools-server/ (ReadFileTool + ReadFileToolTest, with tests covering both classic and interleaved traversal). For a deeper security treatment, the dedicated Securing Production MCP course and its projects/mcp-security/ companion are coming soon.