Tool Exposure Semantics and Security Boundaries
This page covers the most commonly misunderstood aspect of MCP in AI4J:
"A tool can be invoked" and "a tool should be exposed to the model" are not the same thing.
If this boundary is not clarified first, MCP integration easily drifts from "capability augmentation" into "uncontrolled exposure surface".
1. First, separate the three tool sources
In the current AI4J implementation, there are at least three sources of MCP-related tools:
1.1 Local MCP tools
Sourced from:
@McpService@McpTool
Scanned via reflection by ToolUtil.scanMcpTools() and registered into a local cache.
1.2 Remote / gateway MCP tools
Sourced from:
McpGateway- a connected
McpClient
1.3 Legacy Function tools
Sourced from:
@FunctionCall
This shows that the AI4J tool plane is not MCP-only; MCP and legacy Function tools coexist.
2. What getAllTools(...) actually means
The most important entry point right now is:
ToolUtil.getAllTools(functionList, mcpServerIds)
Its real semantics are unambiguous:
- It only merges the function list you explicitly pass in
- It only merges the MCP server list you explicitly pass in
That is, it is not "automatically opening up every tool available in the system".
// Only expose the function and MCP server you explicitly name to the model — allowlist semantics
List<Tool> exposed = ToolUtil.getAllTools(
List.of("queryWeather"), // local function tool
List.of("weather-service")); // MCP server id
This is a very important security design decision.
3. getLocalMcpTools() is not the same semantics
Another entry point that is easy to conflate with it is:
ToolUtil.getLocalMcpTools()
It enumerates every @McpService / @McpTool capability found by the local scan.
It is better suited for:
- Publishing local MCP capabilities
- Enumerating local capabilities
- Organizing the capabilities exposed by an MCP server
// Enumerate every @McpService/@McpTool capability found by the local scan — not an allowlist, an inventory
List<Tool> local = ToolUtil.getLocalMcpTools();
It should not be treated as equivalent to:
- What a regular agent should open up for the current request
So:
getAllTools(...)is effectively a call-site allowlistgetLocalMcpTools()is effectively a local capability enumeration surface
4. Why "explicit pass-in" is more sensible than "open everything by default"
If everything were injected by default, it would cause at least three classes of problems.
4.1 The permission surface grows silently
The caller thinks they have only opened up a few tools, while the model actually receives more capabilities.
4.2 Debugging becomes blurry
Once a tool conflict or mis-invocation occurs, it is hard to tell immediately whether it came from:
- A local MCP tool
- A remote gateway tool
- Or a legacy Function tool
4.3 Approval and audit cannot be stably closed
Only when the exposure surface is explicitly declared at the call site do subsequent allowlists, approvals, traces, and regression audits have a stable foundation.
5. Invocation priority and exposure surface are two separate concerns
This point is critical; many documents conflate the two.
From the real logic of ToolUtil.invoke(...), the invocation priority is roughly:
- built-in tool
- user-level MCP tool
- local MCP tool
- legacy Function tool
- global MCP gateway tool
This means:
- Which tool actually gets executed depends on invocation priority
- Whether a tool should be exposed to the model depends on the allowlist choice in
getAllTools(...)
These are two different questions.
6. How local MCP tools get scanned out
ToolUtil.scanMcpTools() will:
- Reflectively scan classes annotated with
@McpService - Find methods annotated with
@McpToolwithin them - Generate API-friendly tool ids
- Put them into the local
mcpToolCache
So local MCP tools are not hand-registered in some fixed list; they enter the cache through annotation scanning.
What this implies
If you mark a high-risk capability as @McpTool, it is naturally visible within the local capability enumeration surface.
This is exactly why "whether to expose it to the model" must continue to be controlled by the upper-layer allowlist.
7. Why gateway tool exposure also introduces a serviceId
Remote MCP tools are not flattened directly.
The reason getAllTools(functionList, mcpServerIds) requires mcpServerIds is:
- It needs to make explicit which MCP services you allow tools to be pulled from in this round
This turns the tool exposure surface from "by global default" into "by service allowlist".
This is more obvious in user-level scenarios
The current gateway key rules include:
user_{userId}_service_{serviceId}user_{userId}_tool_{toolName}
This shows that user isolation is not an ad-hoc decision either; it is baked into the keys and mapping rules.
8. When to use which exposure approach
Regular agent / chat scenarios
Prefer:
getAllTools(functionList, mcpServerIds)
Because what you want is a request-level allowlist.
MCP server publishing scenarios
You will more often care about:
getLocalMcpTools()
Because what you want to organize is the locally publishable capabilities, not what to open up for the current conversation.
Gateway governance scenarios
You should care more about:
- serviceId selection
- user-level vs global-level mapping
- tool conflicts and source governance
9. What to actually check during code review
The most practical part of this page is really the review checklist.
9.1 Is the exposure surface explicit
Check:
- Whether the call site explicitly passes
functionList - Whether it explicitly passes
mcpServerIds
9.2 Is the local enumeration entry point being misused
Check:
- Whether
getLocalMcpTools()is being used directly as the default tool set for a regular agent
9.3 Are there naming conflicts
Check whether the following may collide, causing invocation priority to behave unexpectedly:
- Local MCP tool id
- Function tool name
- Remote gateway tool name
9.4 Are high-risk tools subject to secondary control
Check whether the following carry extra approval or allowlist gating:
- File system write operations
- Browser automation operations
- External service mutation-style calls
10. The final boundary judgment
If you remember only one sentence:
getAllTools(...)answers "what to open up this time"getLocalMcpTools()answers "what local MCP capabilities exist"ToolUtil.invoke(...)answers "who actually executes it in the end"
Keep these three layers separate, and MCP tool governance will not descend into confusion.