Wiring up MCP servers in LangChain4j is easy. Securing them so an agent can't be tricked into doing something dumb is the actual engineering problem. I broke down how I lock down MCP servers in LangChain4j 1.18, the boundaries that matter, and why "it works in the demo" isn't the same as production-ready.
I broke my own local setup last month. Not dramatically, just an MCP server on Agent UI Editor that I'd wired up for a filesystem tool, sitting on localhost:7801 with zero auth, happily answering to anything that asked. I noticed because a curl command I ran by accident (wrong terminal tab, don't ask) returned a full tool list. No token, no nothing. That's when it hit me: I'd been treating MCP servers like internal implementation details. They're not. They're network endpoints, and the moment you expose one, you've opened a door. If you've been building agent workflows with LangChain4j, you've probably run into MCP (Model Context Protocol) by now. It's become the default way to expose tools to LLM agents without hand-rolling a bespoke tool-calling contract for every integration. I use it in Agent UI Editor, my open source visual editor for building agent graphs: Java 25, Spring Boot 4.0.3 on the backend, LangChain4j 1.18.0 plus the agentic beta module wiring up the graph execution. Once you start connecting real tools, filesystem access, database queries, internal APIs, the security conversation stops being theoretical. Here's my honest take: most of the MCP tutorials I've read skip security entirely. They show you McpClient.builder() , wire it to an agent, and call it done. Fine for a demo. Not fine for anything that touches a real database or a real filesystem. So this time I wanted to write down the actual setup, not just the principles, because principles without code are how people end up with an MCP server on an open port anyway. Why MCP security is different from "normal" API security An MCP server isn't just an API. It's a tool catalog that an LLM reads and decides how to use, autonomously, based on natural language reasoning. That's the part people underestimate. With a regular REST API, you control every call site. You know exactly what endpoint gets hit and why, because a human engineer wrote that line of code. With MCP, the agent decides which tool to call, with what arguments, based on a prompt and a tool description string that the MCP server itself provides. If that description is compromised, or the server is reachable by anyone, you've handed an attacker a menu and a waiter. Three risks stack on top of each other here: Transport exposure : an MCP server listening on an open port with no auth lets anyone on the network enumerate and call your tools directly, bypassing the agent entirely. Tool scope creep : agents tend to get access to broader tool sets than a given task actually needs, because giving the agent everything is easier to wire up than scoping per workflow. Prompt injection through tool metadata : tool descriptions and even tool responses get fed back into the LLM's context. A careless or malicious MCP server can smuggle instructions into a tool result, and the agent might just follow them. None of these are new problems exactly. They're the same class of issue we've dealt with in microservices for years: auth boundaries, least privilege, input sanitation. MCP just puts an LLM in the decision loop, which changes the blast radius. Locking down transport first This is the low-hanging fruit, and there's no excuse for skipping it. LangChain4j's MCP client supports multiple transports: stdio, HTTP with SSE, and the newer streamable HTTP transport as MCP itself evolved. For anything beyond local dev, I use HTTP transport with a bearer token, and I terminate TLS in front of it, behind the same reverse-proxy layer I already run for the React frontend on Agent UI Editor. McpTransport transport = new HttpMcpTransport.Builder() .sseUrl(mcpServerUrl) .headers(Map.of("Authorization", "Bearer " + mcpToken)) .timeout(Duration.ofSeconds(30)) .build(); McpClient mcpClient = new DefaultMcpClient.Builder() .transport(transport) .build(); The token comes from an environment variable, never hardcoded, never checked into the repo. On the proxy side, the check has to happen before the request ever reaches the tool catalog, not after. Here's roughly what that looks like with nginx's auth_request module in front of the MCP process: server { listen 443 ssl; server_name mcp.internal.example.com; ssl_certificate /etc/nginx/certs/mcp.crt; ssl_certificate_key /etc/nginx/certs/mcp.key; location / { auth_request /validate-token; proxy_pass http://127.0.0.1:7801; } location = /validate-token { internal; proxy_pass http://127.0.0.1:7802/internal/validate; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-Authorization $http_authorization; } } And the small Spring Boot endpoint that actually validates the token, running as a sidecar next to the MCP process: @RestController @RequestMapping("/internal") class TokenValidationController { private final McpTokenStore tokenStore; TokenValidationController(McpTokenStore tokenStore) { this.tokenStore = tokenStore; } @GetMapping("/validate") ResponseEntity<Void> validate( @RequestHeader("X-Original-Authorization") String header) { String token = header.replace("Bearer ", "").trim(); if (tokenStore.isValid(token)) { return ResponseEntity.ok().build(); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } } Cheap, effective, and it means a stray curl request gets a 401 instead of a tool catalog. For stdio transport (spawning the MCP server as a local subprocess), the "auth" boundary is really the OS process boundary. Fine for local tools reading your own filesystem, but I wouldn't run anything with write access or network reach through stdio without also sandboxing the subprocess. This is what I actually run for a filesystem MCP tool locally: docker run --rm -i \ --read-only \ --tmpfs /tmp \ --network none \ --mount type=bind,source=/home/jose/agent-ui-editor/workspace,target=/workspace,readonly \ mcp-filesystem-tool:latest Read-only root filesystem, no network, a scoped bind mount. If the tool needs write access to a specific directory, I drop the readonly on that one mount only, never on the whole container. Scoping tools per agent, not per server This is the change that mattered most for me. Early on I had one MCP server exposing a dozen tools (file read, file write, shell exec, a couple of internal API calls) and every agent in the workflow graph got access to all of it. Lazy, and dangerous, because a supervisor agent delegating to a research sub-agent had no business touching the shell exec tool. LangChain4j's McpToolProvider lets you filter which tools actually get surfaced to a given agent, and I lean on this constantly now. I went further and pushed the allowlist into the role definition itself, so nobody accidentally hands a new agent the wrong scope: enum AgentRole { RESEARCH(Set.of("read_file", "list_directory", "web_search")), WRITER(Set.of("read_file", "write_draft")), OPERATOR(Set.of("read_file", "write_file", "shell_exec")); final Set<String> allowedTools; AgentRole(Set<String> allowedTools) { this.allowedTools = allowedTools; } } McpToolProvider toolProviderFor(AgentRole role, McpClient client) { return McpToolProvider.builder() .mcpClients(List.of(client)) .filter(tool -> role.allowedTools.contains(tool.name())) .build(); } Research agent gets read-only tools. Anything that writes to disk or calls a paid API goes to a separate, more restricted agent, and I log every invocation of those separately. Is this more boilerplate? Yes. Worth it? Also yes. I'd rather write an enum with a set of allowed tool names than debug why an agent decided to delete a config file because a tool description told it to "clean up unused files" and it took that literally. Prompt injection through tool responses, the one that actually worries me Here's a scenario that's easy to dismiss...