Skip to main content

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:

  1. Credential isolation: API keys and tokens never enter the agent sandbox
  2. Policy enforcement: Every tool call is checked against your policies
  3. 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 mode and packages
  • --sandbox=gvisor / --sandbox=docker (default): The server runs inside AgentVisor's mcp-tools sandbox, which provides npx/uvx — no host-side Node.js needed.
  • Air-gapped / offline hosts: Use mcp.prepull in mav-agent-config.yaml to 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 — npx must be available on $PATH.
  • Centrally-hosted MCP servers: Use streamable_http to connect to an already-running server instead of spawning a local process:
    mcp:
    servers:
    - name: filesystem
    transport: streamable_http
    url: "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:

  1. Queries the host for available MCP servers
  2. Retrieves tool definitions from each server
  3. Converts them to LangChain StructuredTool instances

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
Policy Simplified for Clarity

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:list and agentvisor:mcp:tool:call are checked per request
Fine-Grained Access

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 .
Running with --sandbox=none instead

--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.

Tool Names

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: