Handle
Connecting…
Back to Workshop
Agent Challenge ~45 min

Connect Multiple MCP Servers

Your agent currently talks to one MCP server. In the real world, tools live on different servers. Let's teach your agent to discover and use tools from multiple sources — including the quest server that runs the workshop's score card.

What You'll Learn

Multi-Server Discovery

Query multiple MCP servers for their tools

Smart Routing

Route tool calls to the correct server

Multiple Services

Connect to other MCP servers over the network

The Quest Server

make up already starts a second MCP server next to your weather one. It hands out the workshop's quests, grades your answers, and drives the live score card — and it speaks exactly the same 2026-07-28 wire format as the server you built, because it is graded by the same contract.

Quest Server Details

From your machine: http://localhost:8004 From inside Docker: http://quest-server:8004 Endpoint: POST /message Protocol: JSON-RPC 2.0 Serves: the quest board

Your agent runs in Docker too, so it must use quest-server:8004localhost inside a container is the container itself.

Try it right now — the stack is already up:

terminal
# List available tools on the quest server
curl -X POST "http://localhost:8004/message" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

# The quest board. Note Mcp-Name: tools/call mirrors params.name,
# and _meta is required on EVERY request — there is no handshake.
curl -X POST "http://localhost:8004/message" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: list_quests" \
  -d '{
    "jsonrpc": "2.0", "id": 2,
    "method": "tools/call",
    "params": {
      "name": "list_quests",
      "arguments": {"difficulty": "novice"},
      "_meta": {"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}
    }
  }'

# The live score card
curl -X POST "http://localhost:8004/message" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: get_scoreboard" \
  -d '{
    "jsonrpc": "2.0", "id": 3,
    "method": "tools/call",
    "params": {
      "name": "get_scoreboard",
      "arguments": {"limit": 10},
      "_meta": {"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}
    }
  }'

It exposes five tools: list_quests, get_quest, submit_answer, get_scoreboard and verify_my_server. None of them appears on your weather server, and none of them ever will — that is what makes this exercise real.

The Challenge

Step 1

Understand Current Discovery

Open services/agent/app.py and find where the agent discovers tools. At startup, it calls tools/list on the MCP server and stores the result. Right now it talks to just one server defined by MCP_SERVER_URL.

Your mission: Make the agent discover tools from multiple MCP servers, merge all tools into one list for OpenAI, and route tool calls to the correct server.

Step 2

Configure Multiple Server URLs

Instead of a single MCP_SERVER_URL, support a comma-separated list of servers. Each server gets a name so you can track which tools came from where.

services/agent/app.py
import os

# Support multiple MCP servers
# Format: "name1=url1,name2=url2" or just "url1,url2"
MCP_SERVERS = {}

def parse_mcp_servers():
    """Parse MCP server configuration from environment."""
    # Keep backward compatibility with single server
    single_url = os.getenv("MCP_SERVER_URL")
    if single_url:
        MCP_SERVERS["local"] = single_url

    # Support additional servers
    extra = os.getenv("MCP_EXTRA_SERVERS", "")
    for entry in extra.split(","):
        entry = entry.strip()
        if not entry:
            continue
        if "=" in entry:
            name, url = entry.split("=", 1)
            MCP_SERVERS[name.strip()] = url.strip()
        else:
            MCP_SERVERS[f"server-{len(MCP_SERVERS)}"] = entry

parse_mcp_servers()

Update docker-compose.yml to point the agent at the quest server:

docker-compose.yml
travel-agent:
  environment:
    - MCP_SERVER_URL=http://mcp-server:8000
    - MCP_EXTRA_SERVERS=quests=http://quest-server:8004  # <-- Add this!
Step 3

Discover Tools From All Servers

Now update the tool discovery to loop through all configured servers. The key insight: you need to remember which server each tool came from, so you can route calls correctly later.

services/agent/app.py
# Maps tool_name → server_url (so we know where to send calls)
tool_to_server: Dict[str, str] = {}

# All discovered tools (merged from all servers)
all_tools: list = []

async def discover_all_tools():
    """Discover tools from every configured MCP server."""
    global all_tools, tool_to_server
    all_tools = []
    tool_to_server = {}

    for name, url in MCP_SERVERS.items():
        try:
            logger.info(f"Discovering tools from {name} ({url})...")
            async with httpx.AsyncClient() as client:
                resp = await client.post(
                    f"{url}/message",
                    json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
                    timeout=10.0
                )
            tools = resp.json()["result"]["tools"]

            for tool in tools:
                tool_name = tool["name"]
                if tool_name in tool_to_server:
                    logger.warning(f"Duplicate tool '{tool_name}' from {name}, skipping")
                    continue
                tool_to_server[tool_name] = url
                all_tools.append(tool)

            logger.info(f"Found {len(tools)} tools from {name}")
        except Exception as e:
            logger.error(f"Failed to discover tools from {name} ({url}): {e}")

    logger.info(f"Total tools discovered: {len(all_tools)}")

Key pattern: The tool_to_server dictionary is your routing table. When the model says "call get_scoreboard", you look up which server owns that tool and forward the request there.

Step 4

Route Tool Calls to the Right Server

Find where the agent handles tool calls from OpenAI. Instead of always sending to the same server, look up the correct server from tool_to_server.

services/agent/app.py
async def call_mcp_tool(tool_name: str, arguments: dict) -> str:
    """Call an MCP tool on the correct server."""
    server_url = tool_to_server.get(tool_name)

    if not server_url:
        return f"Error: Unknown tool '{tool_name}'. Available: {list(tool_to_server.keys())}"

    logger.info(f"Calling {tool_name} on {server_url}")

    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"{server_url}/message",
            json={
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": {
                    "name": tool_name,
                    "arguments": arguments
                }
            },
            timeout=30.0
        )

    result = resp.json()["result"]
    return result["content"][0]["text"]

That's it. The routing is simple because every MCP server speaks the exact same protocol. The agent doesn't need to know whether a tool is local or remote — same JSON-RPC call either way.

Step 5

Handle Server Failures Gracefully

When you connect to other servers, things will fail sometimes. Network issues, server downtime, slow responses. Your agent should handle these gracefully without crashing.

services/agent/app.py
async def call_mcp_tool_safe(tool_name: str, arguments: dict) -> str:
    """Call an MCP tool with error handling."""
    try:
        return await call_mcp_tool(tool_name, arguments)
    except httpx.ConnectError:
        server = tool_to_server.get(tool_name, "unknown")
        return f"The server hosting '{tool_name}' is unreachable. It may be down temporarily."
    except httpx.ReadTimeout:
        return f"The tool '{tool_name}' took too long to respond. Try again shortly."
    except Exception as e:
        logger.error(f"Tool call failed: {tool_name} - {e}")
        return f"Error calling {tool_name}: {str(e)}"

Why this matters: If the quest server is down, the agent should still be able to answer weather questions. OpenAI will see the error message and explain to the user what happened.

Step 6

Test Multi-Server Integration

Rebuild and test that your agent discovers tools from both servers:

terminal
# Rebuild and restart
docker compose build travel-agent && docker compose up -d

# Check the logs — you should see tools from BOTH servers
docker compose logs travel-agent | grep -i "tools\|discover"

# Ask for weather (local MCP server)
curl -X POST "http://localhost:8001/query" \
  -H "Content-Type: application/json" \
  -d '{"query": "What is the weather in Oslo?"}'

# Ask about quests (the quest server!)
curl -X POST "http://localhost:8001/query" \
  -H "Content-Type: application/json" \
  -d '{"query": "Which quests are still unsolved, and who is leading?"}'

# The big test — ask for BOTH in one query
curl -X POST "http://localhost:8001/query" \
  -H "Content-Type: application/json" \
  -d '{"query": "What is the weather in Bergen, and which novice quest should I take next?"}'

When the agent answers the last query, it will call get_weather_forecast on your local server AND list_quests on the quest server. Two servers, one seamless response.

Stretch Goal

Add Health-Aware Discovery

Right now, tool discovery happens once at startup. What if a server goes down and comes back? Try adding periodic re-discovery that checks /health on each server and refreshes the tool list when a server recovers.

Hints:

  • Use asyncio.create_task() to run a background loop
  • Check GET /health on each server every 60 seconds
  • Only re-discover if a previously-down server comes back healthy
  • The quest server exposes /health at http://quest-server:8004/health

You've Nailed It When...

Agent logs show tools discovered from both servers
Weather queries still work (routed to the weather server)
Quest queries work (routed to the quest server)
A single query can use tools from both servers in one response
If the quest server is down, weather still works (graceful degradation)