Connecting External Tools (MCP)
So far your agents have been limited to what they can do with LLM calls alone. MCP (Model Context Protocol) extends your agent's capabilities by connecting it to external tools and data sources — file systems, APIs, databases, and more with AgentVisor™.
What You'll Learn
- Configuring MCP servers in
agentvisor.yaml - Loading MCP tools in your agent code
- Adding policies to control MCP tool access
- Testing tool integration end-to-end
Prerequisites
Ensure Temporal and Ollama are still running — they were started with docker compose up -d inside chatbot-agent back in Your First Agent and run detached, so they're still up unless you've since run docker compose down there. chatbot-agent is the only project directory with a compose.yml; if you need to restart the services, cd into it and run docker compose up -d from there before continuing.
The MCP filesystem server needs Node.js — but only if you run with --sandbox=none (see the sandbox mode note in Configuring an MCP Server below). With the default Docker/gVisor sandbox, AgentVisor provides Node.js inside its own mcp-tools sandbox, so no host-side install is required.
How MCP Works in AgentVisor
Unlike traditional MCP integrations where agents connect directly to MCP servers, AgentVisor places MCP servers on the host side. This provides critical security benefits:
- Credential isolation: API keys and tokens never enter the agent sandbox
- Policy enforcement: Every tool call is checked against your policies
- Automatic discovery: Agents discover tools without hardcoded configuration
Agent (sandbox) Host Runtime
┌──────────────┐ ┌─────────────────────────┐
│ get_mcp_tools│────gRPC────▶│ Policy check ──▶ MCP │
│ call_tool │◀────────────│ servers │
└──────────────┘ └─────────────────────────┘
The agent calls get_mcp_tools() and receives tools it's authorized to use. When calling a tool, the request flows through the host runtime where policies are enforced before reaching the MCP server.
Configuring an MCP Server
Create a project directory with an MCP server configured, as a sibling of chatbot-agent (not nested inside it):
mkdir -p my-mcp-agent/policies
cd my-mcp-agent
agentvisor.yaml
This agent's agent.py (below) talks to Ollama at its default OLLAMA_BASE_URL of http://localhost:11434, a loopback destination the proxy's SSRF guard blocks unless explicitly allowlisted — the same fix applied to the chatbot-agent template:
proxy:
ssrf_allowed_cidrs:
- "127.0.0.1/32"
- "::1/128"
mcp:
servers:
- name: filesystem
transport: stdio
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp/mcp-data"]
This configuration tells AgentVisor to launch the MCP filesystem server as a sandboxed subprocess. With --sandbox=gvisor or --sandbox=docker the server runs in an isolated mcp-tools container (node/npx pre-installed), so no local Node.js installation is required on the host.
--sandbox=gvisor/--sandbox=docker(default): The server runs inside AgentVisor'smcp-toolssandbox, which providesnpx/uvx— no host-side Node.js needed.- Air-gapped / offline hosts: Use
mcp.prepullinmav-agent-config.yamlto pre-install packages into the sandbox image so no outbound network access is needed at runtime. See Pre-installing Packages (prepull). --sandbox=none(development only): The server runs as a host subprocess without isolation —npxmust be available on$PATH.- Centrally-hosted MCP servers: Use
streamable_httpto connect to an already-running server instead of spawning a local process:mcp:servers:- name: filesystemtransport: streamable_httpurl: "http://mcp-filesystem-server:8080"
See MCP Gateway Guide: Sandboxed Execution of stdio Servers and Configuration Reference: MCP for all transport options.
Prepare some test data:
mkdir -p /tmp/mcp-data
echo 'Hello from MCP!' > /tmp/mcp-data/readme.txt
echo '{"version": "1.0"}' > /tmp/mcp-data/config.json
Using MCP Tools in Agent Code
agent.py
"""Agent that uses MCP tools for file operations."""
import os
from typing import Annotated, TypedDict
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_ollama import ChatOllama
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from agentvisor.langgraph import AgentVisorCheckpointer
from agentvisor.langchain import get_mcp_tools
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2:1b")
SYSTEM_PROMPT = """You are a helpful assistant with access to file system tools.
Use the available tools to help users read and manage files.
Always explain what you're doing and present results clearly."""
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
# Load MCP tools from host configuration
tools = get_mcp_tools()
def get_llm() -> ChatOllama:
"""Create LLM with tool binding."""
llm = ChatOllama(
base_url=OLLAMA_BASE_URL,
model=OLLAMA_MODEL,
temperature=0,
)
if tools:
return llm.bind_tools(tools)
return llm
def agent(state: AgentState) -> dict:
"""Process messages and optionally call tools."""
messages = state.get("messages", [])
conversation = [SystemMessage(content=SYSTEM_PROMPT)]
for msg in messages:
if isinstance(msg, (HumanMessage, AIMessage, SystemMessage, ToolMessage)):
conversation.append(msg)
elif isinstance(msg, dict):
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "user":
conversation.append(HumanMessage(content=content))
elif role == "assistant":
conversation.append(AIMessage(content=content))
response = get_llm().invoke(conversation)
return {"messages": [response]}
def should_continue(state: AgentState) -> str:
"""Route to tools or end based on LLM response."""
messages = state.get("messages", [])
if not messages:
return "end"
last = messages[-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "tools"
return "end"
# Build the ReAct graph
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
builder.set_entry_point("agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=AgentVisorCheckpointer())
The key line is tools = get_mcp_tools(). This function:
- Queries the host for available MCP servers
- Retrieves tool definitions from each server
- Converts them to LangChain
StructuredToolinstances
No tool definitions in your agent code — tools are discovered at startup from the host's MCP configuration.
langgraph.json
{
"dependencies": ["."],
"graphs": {
"mcp_demo": "./agent.py:graph"
}
}
requirements.txt
langgraph>=0.2.0
langchain-core>=0.3.0
langchain-ollama>=0.2.0
Adding MCP Policies
MCP tool access follows the same policy model as HTTP requests. Tools are identified by MRN (Manetu Resource Name):
mrn:agentvisor:mcp:<server>/<tool>
Examples:
mrn:agentvisor:mcp:filesystem/read_file
mrn:agentvisor:mcp:filesystem/list_directory
policies/domain.yml
apiVersion: iamlite.manetu.io/v1beta1
kind: PolicyDomain
metadata:
name: mcp-demo
spec:
policies:
# Operation phase - allow authenticated requests
- mrn: &policy-operation "mrn:iam:policy:operation"
name: operation
rego: |
package authz
default allow = 0
# Allow policy
- mrn: &policy-allow "mrn:iam:policy:allow"
name: allow
rego: |
package authz
default allow = true
# Deny policy
- mrn: &policy-deny "mrn:iam:policy:deny"
name: deny
rego: |
package authz
default allow = false
roles:
- mrn: "mrn:agentvisor:role:anonymous"
name: anonymous
policy: *policy-allow
operations:
- name: all-ops
selector: ["agentvisor:.*"]
policy: *policy-operation
resource-groups:
# AgentVisor API resources
- mrn: "mrn:agentvisor:resourcegroup:threads"
name: threads
policy: *policy-allow
- mrn: "mrn:agentvisor:resourcegroup:runs"
name: runs
policy: *policy-allow
- mrn: "mrn:agentvisor:resourcegroup:state"
name: state
policy: *policy-allow
- mrn: "mrn:agentvisor:resourcegroup:agents"
name: agents
policy: *policy-allow
- mrn: "mrn:agentvisor:resourcegroup:store"
name: store
policy: *policy-allow
# MCP tools and resources - allow all for this demo
- mrn: &mcp-group "mrn:agentvisor:resourcegroup:mcp"
name: mcp
policy: *policy-allow
# HTTP for Ollama
- mrn: &ollama-group "mrn:agentvisor:resourcegroup:ollama"
name: ollama
policy: *policy-allow
# Default deny
- mrn: "mrn:agentvisor:resourcegroup:default"
name: default
default: true
policy: *policy-deny
resources:
# Route all MCP operations to the MCP resource group
- name: mcp-tools
selector:
- "mrn:agentvisor:mcp:.*"
group: *mcp-group
# Route Ollama HTTP to allowed group
- name: ollama
selector:
- "mrn:agentvisor:http:localhost:11434.*"
- "mrn:agentvisor:http:ollama.*"
group: *ollama-group
The policy above is simplified for tutorial purposes. Production policies typically include additional resource groups, more granular selectors, and comprehensive annotations. See the full policies/domain.yml in the MCP Agent example for a complete working policy.
The key MCP policy elements:
- Resource selector
mrn:agentvisor:mcp:.*matches all MCP tools and resources - Operations like
agentvisor:mcp:tool:listandagentvisor:mcp:tool:callare checked per request
For production, restrict access per server or tool:
resources:
# Allow read operations only
- name: mcp-readonly
selector:
- "mrn:agentvisor:mcp:filesystem/read_file"
- "mrn:agentvisor:mcp:filesystem/list_directory"
group: *mcp-group
# Block write operations
- name: mcp-blocked
selector:
- "mrn:agentvisor:mcp:filesystem/write_file"
- "mrn:agentvisor:mcp:filesystem/delete_file"
group: "mrn:agentvisor:resourcegroup:default"
Testing Your MCP Integration
Start the agent:
agentvisor serve .
--sandbox=none runs the guest without container isolation and requires a local Python environment with the AgentVisor SDK installed, plus Node.js on $PATH for the filesystem server — see Local Development Setup. The default sandbox mode used above needs neither.
In another terminal, test MCP tool usage:
# Create a thread
THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')
# Ask the agent to list files
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "List the files in the data directory"}]}}' \
| jq '.output.messages[-1].content'
# Ask the agent to read a file
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "Read the readme.txt file"}]}}' \
| jq '.output.messages[-1].content'
You should see the agent use the filesystem tools to list and read files.
MCP tools are prefixed with the server name to avoid conflicts: filesystem__read_file, filesystem__list_directory. The LLM sees these as distinct tools and calls them by their full names.
Next Steps
You've learned how to extend your agent with external tools via MCP. Continue exploring:
- MCP Integration — Architecture, security model, credential injection
- MCP SDK Reference — Complete API documentation
- MCP Agent Example — Full working example with ReAct pattern
- Deploying Your Agent — Build images and deploy to production