Files
2026-07-13 13:31:35 +08:00

685 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adversarial Multi-Agent Reasoning with MCP
Multi-agent debate patterns use two or more agents with opposing positions to produce more reliable and well-calibrated outputs than a single agent can achieve alone.
## Introduction
In this lesson, we explore the **adversarial multi-agent pattern** — a technique where two AI agents are assigned opposing positions on a topic and must reason, call MCP tools, and challenge each other's conclusions. A third agent (or a human reviewer) then evaluates the arguments and determines the best outcome.
This pattern is especially useful for:
- **Hallucination detection**: A second agent challenges unsubstantiated claims the first agent makes.
- **Threat modeling and security reviews**: One agent argues that a system is safe; the other looks for vulnerabilities.
- **API or requirements design**: One agent defends a proposed design; the other raises objections.
- **Factual verification**: Both agents independently query the same MCP tools and cross-check each other's conclusions.
By sharing the same MCP tool set, both agents operate in the same information environment — which means any disagreement reflects genuine reasoning differences rather than an information asymmetry.
## Learning Objectives
By the end of this lesson, you will be able to:
- Explain why adversarial multi-agent patterns catch errors that single-agent pipelines miss.
- Design a debate architecture where two agents share a common MCP tool set.
- Implement "for" and "against" system prompts that guide each agent to argue its assigned position.
- Add a judge agent (or human review step) that synthesizes the debate into a final verdict.
- Understand how MCP tool-sharing works across concurrent agents.
## Architecture Overview
The adversarial pattern follows this high-level flow:
```mermaid
flowchart TD
Topic([Debate Topic / Claim]) --> ForAgent
Topic --> AgainstAgent
subgraph SharedMCPServer["Shared MCP Tool Server"]
WebSearch[Web Search Tool]
CodeExec[Code Execution Tool]
DocReader[Optional: Document Reader Tool]
end
ForAgent["Agent A\n(Argues FOR)"] -->|Tool calls| SharedMCPServer
AgainstAgent["Agent B\n(Argues AGAINST)"] -->|Tool calls| SharedMCPServer
SharedMCPServer -->|Results| ForAgent
SharedMCPServer -->|Results| AgainstAgent
ForAgent -->|Opening argument| Debate[(Debate Transcript)]
AgainstAgent -->|Rebuttal| Debate
ForAgent -->|Counter-rebuttal| Debate
AgainstAgent -->|Counter-rebuttal| Debate
Debate --> JudgeAgent["Judge Agent\n(Evaluates arguments)"]
JudgeAgent --> Verdict([Final Verdict & Reasoning])
style ForAgent fill:#c2f0c2,stroke:#333
style AgainstAgent fill:#f9d5e5,stroke:#333
style JudgeAgent fill:#d5e8f9,stroke:#333
style SharedMCPServer fill:#fff9c4,stroke:#333
```
### Key design decisions
| Decision | Rationale |
|----------|-----------|
| Both agents share one MCP server | Eliminates information asymmetry — disagreements reflect reasoning, not data access |
| Agents have opposing system prompts | Forces each agent to stress-test the other side's position |
| A judge agent synthesizes the debate | Produces a single actionable output without human bottleneck |
| Multiple debate rounds | Allows each agent to respond to the other's tool-backed evidence |
## Implementation
### Step 1 — Shared MCP Tool Server
Start by exposing the tools that both agents will call. In this example we use a minimal Python MCP server built with FastMCP.
<details>
<summary>Python Shared Tool Server</summary>
```python
# shared_tools_server.py
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("debate-tools")
@mcp.tool()
async def web_search(query: str) -> str:
"""Search the web and return a short summary of the top results."""
# Replace with your preferred search API (e.g., SerpAPI, Brave Search).
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.search.example.com/search",
params={"q": query, "num": 3},
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
response.raise_for_status()
results = response.json().get("results", [])
snippets = "\n".join(r["snippet"] for r in results)
return f"Search results for '{query}':\n{snippets}"
@mcp.tool()
async def run_python(code: str) -> str:
"""Execute a Python snippet and return stdout + stderr.
WARNING: This is an unsafe placeholder that runs code directly on the host.
In production, replace with a sandboxed execution environment (e.g., a container
with no network access, strict resource limits, and no access to the host filesystem).
"""
import subprocess, sys, textwrap
result = subprocess.run(
[sys.executable, "-c", textwrap.dedent(code)],
capture_output=True, text=True, timeout=10
)
return result.stdout + result.stderr
if __name__ == "__main__":
mcp.run(transport="stdio")
```
Run with:
```bash
python shared_tools_server.py
```
</details>
<details>
<summary>TypeScript Shared Tool Server</summary>
```typescript
// shared-tools-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const server = new McpServer({ name: "debate-tools", version: "1.0.0" });
server.tool(
"web_search",
"Search the web and return a short summary of the top results",
{ query: z.string() },
async ({ query }) => {
// Replace with your preferred search API.
const url = `https://api.search.example.com/search?q=${encodeURIComponent(query)}&num=3`;
const response = await fetch(url, {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const data = (await response.json()) as { results: { snippet: string }[] };
const snippets = data.results.map((r) => r.snippet).join("\n");
return {
content: [{ type: "text", text: `Search results for '${query}':\n${snippets}` }],
};
}
);
server.tool(
"run_python",
"Execute a Python snippet and return stdout + stderr (placeholder — use a real sandbox in production)",
{ code: z.string() },
async ({ code }) => {
// WARNING: This executes LLM-controlled code directly on the host process.
// In production, always run inside an isolated sandbox (e.g., a container
// with no network access and strict resource limits).
// See the Security Considerations section for details.
try {
// Pass code as a direct argument to python3 — no shell invocation,
// no string interpolation, no command-injection risk.
const { stdout, stderr } = await execFileAsync("python3", ["-c", code], {
timeout: 10000,
});
return { content: [{ type: "text", text: stdout + stderr }] };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `Error: ${message}` }] };
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
```
Run with:
```bash
npx ts-node shared-tools-server.ts
```
</details>
---
### Step 2 — Agent System Prompts
Each agent receives a system prompt that locks it into its assigned position. The key is that both agents know they are in a debate and that they *must* use tools to back their claims.
<details>
<summary>Python System Prompts</summary>
```python
# prompts.py
FOR_SYSTEM_PROMPT = """You are Agent A in a structured debate.
Your role is to argue *in favour* of the proposition given to you.
Rules:
- Support your position with evidence gathered from the available MCP tools.
- Call the web_search tool to find real supporting data.
- Call the run_python tool to verify quantitative claims with code.
- When your opponent makes a claim, challenge it specifically and with evidence.
- Do not concede your position unless your opponent provides irrefutable evidence.
- Keep each turn concise (≤ 200 words)."""
AGAINST_SYSTEM_PROMPT = """You are Agent B in a structured debate.
Your role is to argue *against* the proposition given to you.
Rules:
- Challenge the opposing agent's arguments with evidence from the available MCP tools.
- Call the web_search tool to find counter-evidence.
- Call the run_python tool to verify or disprove quantitative claims with code.
- Point out logical fallacies, missing context, or unsupported assertions.
- Do not concede your position unless the evidence is irrefutable.
- Keep each turn concise (≤ 200 words)."""
JUDGE_SYSTEM_PROMPT = """You are an impartial judge evaluating a structured debate.
Your task:
1. Read the full debate transcript.
2. Identify the strongest evidence-backed arguments on each side.
3. Note any claims that were left unchallenged.
4. Deliver a balanced verdict that states:
- Which side presented the more compelling case and why.
- Key caveats or nuances that neither side addressed adequately.
- A confidence score (0100) for the winning position."""
```
</details>
---
### Step 3 — Debate Orchestrator
The orchestrator creates both agents, manages the debate turns, then passes the full transcript to the judge.
<details>
<summary>Python Debate Orchestrator</summary>
```python
# debate_orchestrator.py
import asyncio
from anthropic import AsyncAnthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from prompts import FOR_SYSTEM_PROMPT, AGAINST_SYSTEM_PROMPT, JUDGE_SYSTEM_PROMPT
client = AsyncAnthropic()
NUM_ROUNDS = 3 # Number of back-and-forth exchange rounds
async def run_agent_turn(
conversation_history: list[dict],
system_prompt: str,
session: ClientSession,
) -> str:
"""Run one agent turn with MCP tool support.
Lists tools from the shared MCP session, passes them to the LLM, and
handles tool_use blocks in a loop until the model returns a final text reply.
"""
# Fetch the current tool list from the shared MCP server.
tools_result = await session.list_tools()
tools = [
{
"name": t.name,
"description": t.description or "",
"input_schema": t.inputSchema,
}
for t in tools_result.tools
]
messages = list(conversation_history)
while True:
response = await client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
system=system_prompt,
messages=messages,
tools=tools,
)
# Collect any text the model produced.
text_blocks = [b for b in response.content if b.type == "text"]
# If the model is done (no tool calls), return its text reply.
tool_uses = [b for b in response.content if b.type == "tool_use"]
if not tool_uses:
return text_blocks[0].text if text_blocks else ""
# Record the assistant turn (may mix text + tool_use blocks).
messages.append({"role": "assistant", "content": response.content})
# Execute each tool call and collect results.
tool_results = []
for tool_use in tool_uses:
result = await session.call_tool(tool_use.name, tool_use.input)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result.content[0].text if result.content else "",
}
)
# Feed the tool results back to the model.
messages.append({"role": "user", "content": tool_results})
async def run_debate(proposition: str) -> dict:
"""
Run a full adversarial debate on a proposition.
Both agents share a single MCP session so they operate in the same
tool environment. Returns a dictionary with the transcript and verdict.
"""
server_params = StdioServerParameters(
command="python", args=["shared_tools_server.py"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
transcript: list[dict] = []
# Seed the debate with the proposition.
opening_message = {"role": "user", "content": f"Proposition: {proposition}"}
for_history: list[dict] = [opening_message]
against_history: list[dict] = [opening_message]
for round_num in range(1, NUM_ROUNDS + 1):
print(f"\n--- Round {round_num} ---")
# Agent A argues FOR.
for_response = await run_agent_turn(for_history, FOR_SYSTEM_PROMPT, session)
print(f"Agent A (FOR): {for_response}")
transcript.append({"round": round_num, "agent": "FOR", "text": for_response})
# Share Agent A's argument with Agent B.
for_history.append({"role": "assistant", "content": for_response})
against_history.append({"role": "user", "content": f"Opponent argued: {for_response}"})
# Agent B argues AGAINST.
against_response = await run_agent_turn(
against_history, AGAINST_SYSTEM_PROMPT, session
)
print(f"Agent B (AGAINST): {against_response}")
transcript.append({"round": round_num, "agent": "AGAINST", "text": against_response})
# Share Agent B's argument with Agent A for the next round.
against_history.append({"role": "assistant", "content": against_response})
for_history.append({"role": "user", "content": f"Opponent argued: {against_response}"})
# Build the transcript summary for the judge.
transcript_text = "\n\n".join(
f"Round {t['round']} {t['agent']}:\n{t['text']}" for t in transcript
)
judge_input = [
{
"role": "user",
"content": f"Proposition: {proposition}\n\nDebate transcript:\n{transcript_text}",
}
]
# Judge evaluates the debate.
verdict = await run_agent_turn(judge_input, JUDGE_SYSTEM_PROMPT, session)
print(f"\n=== Judge Verdict ===\n{verdict}")
return {"transcript": transcript, "verdict": verdict}
if __name__ == "__main__":
proposition = (
"Large language models will eliminate the need for junior software developers within five years."
)
result = asyncio.run(run_debate(proposition))
```
</details>
<details>
<summary>TypeScript Debate Orchestrator</summary>
```typescript
// debate-orchestrator.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const FOR_SYSTEM_PROMPT = `You are Agent A in a structured debate.
Your role is to argue *in favour* of the proposition given to you.
Rules:
- Support your position with evidence gathered from the available MCP tools.
- Call the web_search tool to find real supporting data.
- When your opponent makes a claim, challenge it specifically and with evidence.
- Keep each turn concise (≤ 200 words).`;
const AGAINST_SYSTEM_PROMPT = `You are Agent B in a structured debate.
Your role is to argue *against* the proposition given to you.
Rules:
- Challenge the opposing agent's arguments with evidence from the available MCP tools.
- Call the web_search tool to find counter-evidence.
- Point out logical fallacies, missing context, or unsupported assertions.
- Keep each turn concise (≤ 200 words).`;
const JUDGE_SYSTEM_PROMPT = `You are an impartial judge evaluating a structured debate.
Deliver a verdict with:
1. Which side presented the more compelling case and why.
2. Key caveats or nuances that neither side addressed.
3. A confidence score (0100) for the winning position.`;
type Message = { role: "user" | "assistant"; content: string };
type DebateTurn = { round: number; agent: "FOR" | "AGAINST"; text: string };
async function runAgentTurn(history: Message[], systemPrompt: string): Promise<string> {
const response = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 512,
system: systemPrompt,
messages: history,
});
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
if (!text) {
const blockTypes = response.content.map((block) => block.type).join(", ");
throw new Error(
`Expected at least one text response block, but received: ${blockTypes || "none"}`
);
}
return text;
}
async function runDebate(
proposition: string,
numRounds = 3
): Promise<{ transcript: DebateTurn[]; verdict: string }> {
const transcript: DebateTurn[] = [];
const openingMessage: Message = { role: "user", content: `Proposition: ${proposition}` };
const forHistory: Message[] = [openingMessage];
const againstHistory: Message[] = [openingMessage];
for (let round = 1; round <= numRounds; round++) {
console.log(`\n--- Round ${round} ---`);
// Agent A (FOR)
const forResponse = await runAgentTurn(forHistory, FOR_SYSTEM_PROMPT);
console.log(`Agent A (FOR): ${forResponse}`);
transcript.push({ round, agent: "FOR", text: forResponse });
forHistory.push({ role: "assistant", content: forResponse });
againstHistory.push({ role: "user", content: `Opponent argued: ${forResponse}` });
// Agent B (AGAINST)
const againstResponse = await runAgentTurn(againstHistory, AGAINST_SYSTEM_PROMPT);
console.log(`Agent B (AGAINST): ${againstResponse}`);
transcript.push({ round, agent: "AGAINST", text: againstResponse });
againstHistory.push({ role: "assistant", content: againstResponse });
forHistory.push({ role: "user", content: `Opponent argued: ${againstResponse}` });
}
// Judge
const transcriptText = transcript
.map((t) => `Round ${t.round} ${t.agent}:\n${t.text}`)
.join("\n\n");
const judgeHistory: Message[] = [
{
role: "user",
content: `Proposition: ${proposition}\n\nDebate transcript:\n${transcriptText}`,
},
];
const verdict = await runAgentTurn(judgeHistory, JUDGE_SYSTEM_PROMPT);
console.log(`\n=== Judge Verdict ===\n${verdict}`);
return { transcript, verdict };
}
// Run
const proposition =
"Large language models will eliminate the need for junior software developers within five years.";
runDebate(proposition).catch(console.error);
```
</details>
<details>
<summary>C# Debate Orchestrator</summary>
```csharp
// DebateOrchestrator.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
public class DebateOrchestrator
{
private const string Model = "claude-opus-4-5";
private readonly AnthropicClient _client = new();
private const string ForSystemPrompt = @"You are Agent A in a structured debate.
Your role is to argue *in favour* of the proposition given to you.
Rules:
- Support your position with evidence.
- Challenge your opponent's claims specifically.
- Keep each turn concise (≤ 200 words).";
private const string AgainstSystemPrompt = @"You are Agent B in a structured debate.
Your role is to argue *against* the proposition given to you.
Rules:
- Challenge the opposing agent's arguments with evidence.
- Point out logical fallacies or unsupported assertions.
- Keep each turn concise (≤ 200 words).";
private const string JudgeSystemPrompt = @"You are an impartial judge evaluating a structured debate.
Deliver a verdict with:
1. Which side presented the more compelling case and why.
2. Key caveats neither side addressed.
3. A confidence score (0100) for the winning position.";
private record DebateTurn(int Round, string Agent, string Text);
private async Task<string> RunAgentTurnAsync(
List<Message> history,
string systemPrompt)
{
var request = new MessageParameters
{
Model = Model,
MaxTokens = 512,
System = [new SystemMessage(systemPrompt)],
Messages = history
};
var response = await _client.Messages.GetClaudeMessageAsync(request);
return response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? string.Empty;
}
public async Task<(List<DebateTurn> Transcript, string Verdict)> RunDebateAsync(
string proposition,
int numRounds = 3)
{
var transcript = new List<DebateTurn>();
var opening = new Message { Role = RoleType.User, Content = $"Proposition: {proposition}" };
var forHistory = new List<Message> { opening };
var againstHistory = new List<Message> { opening };
for (int round = 1; round <= numRounds; round++)
{
Console.WriteLine($"\n--- Round {round} ---");
// Agent A (FOR)
var forResponse = await RunAgentTurnAsync(forHistory, ForSystemPrompt);
Console.WriteLine($"Agent A (FOR): {forResponse}");
transcript.Add(new DebateTurn(round, "FOR", forResponse));
forHistory.Add(new Message { Role = RoleType.Assistant, Content = forResponse });
againstHistory.Add(new Message { Role = RoleType.User, Content = $"Opponent argued: {forResponse}" });
// Agent B (AGAINST)
var againstResponse = await RunAgentTurnAsync(againstHistory, AgainstSystemPrompt);
Console.WriteLine($"Agent B (AGAINST): {againstResponse}");
transcript.Add(new DebateTurn(round, "AGAINST", againstResponse));
againstHistory.Add(new Message { Role = RoleType.Assistant, Content = againstResponse });
forHistory.Add(new Message { Role = RoleType.User, Content = $"Opponent argued: {againstResponse}" });
}
// Judge
var transcriptText = string.Join("\n\n",
transcript.Select(t => $"Round {t.Round} {t.Agent}:\n{t.Text}"));
var judgeHistory = new List<Message>
{
new() { Role = RoleType.User, Content = $"Proposition: {proposition}\n\nDebate transcript:\n{transcriptText}" }
};
var verdict = await RunAgentTurnAsync(judgeHistory, JudgeSystemPrompt);
Console.WriteLine($"\n=== Judge Verdict ===\n{verdict}");
return (transcript, verdict);
}
public static async Task Main()
{
var orchestrator = new DebateOrchestrator();
const string proposition =
"Large language models will eliminate the need for junior software developers within five years.";
await orchestrator.RunDebateAsync(proposition);
}
}
```
</details>
---
### Step 4 — Wiring MCP Tools into the Agents
The Python orchestrator above already shows the complete MCP-wired implementation. The key pattern is:
- **One shared session**: `run_debate` opens a single `ClientSession` and passes it to every `run_agent_turn` call, so both agents and the judge operate in the same tool environment.
- **Tool listing per turn**: `run_agent_turn` calls `session.list_tools()` to fetch the current tool definitions and forwards them to the LLM as the `tools` parameter.
- **Tool-use loop**: When the model returns `tool_use` blocks, `run_agent_turn` calls `session.call_tool()` for each one and feeds the results back to the model, repeating until the model produces a final text response.
Refer to [03-GettingStarted/02-client](../../03-GettingStarted/02-client/solution/) for complete MCP client examples in each language.
---
## Practical Use Cases
| Use Case | FOR Agent | AGAINST Agent | Judge Output |
|----------|-----------|---------------|--------------|
| **Threat modeling** | "This API endpoint is secure" | "Here are five attack vectors" | Prioritised risk list |
| **API design review** | "This design is optimal" | "These trade-offs are problematic" | Recommended design with caveats |
| **Factual verification** | "Claim X is supported by evidence" | "Evidence Y contradicts claim X" | Confidence-rated verdict |
| **Technology selection** | "Choose framework A" | "Framework B is better for these reasons" | Decision matrix with recommendation |
---
## Security Considerations
When running adversarial agents in production, keep these points in mind:
- **Sandbox code execution**: The `run_python` tool must execute in an isolated environment (e.g., a container with no network access and resource limits). Never run untrusted LLM-generated code directly on the host.
- **Tool call validation**: Validate all tool inputs before execution. Both agents share the same tool server, so a malicious prompt injected into the debate could attempt to misuse tools.
- **Rate limiting**: Implement per-agent rate limits on tool calls to prevent runaway loops.
- **Audit logging**: Log every tool call and result so you can review what evidence each agent used to reach its conclusions.
- **Human-in-the-loop**: For high-stakes decisions, route the judge's verdict through a human reviewer before acting on it.
See [02-Security](../../02-Security/) for a comprehensive guide to MCP security best practices.
---
## Exercise
Design an adversarial MCP pipeline for one of the following scenarios:
1. **Code review**: Agent A defends a pull request; Agent B looks for bugs, security issues, and style problems. The judge summarises the top issues.
2. **Architecture decision**: Agent A proposes microservices; Agent B advocates for a monolith. The judge produces a decision matrix.
3. **Content moderation**: Agent A argues a piece of content is safe to publish; Agent B finds policy violations. The judge assigns a risk score.
For each scenario:
- Define the system prompts for both agents and the judge.
- Identify which MCP tools each agent needs.
- Sketch the message flow (opening argument → rebuttal → counter-rebuttal → verdict).
- Describe how you would validate the judge's verdict before acting on it.
---
## Key Takeaways
- Adversarial multi-agent patterns use opposing system prompts to force agents to stress-test each other's reasoning.
- Sharing a single MCP tool server ensures both agents work from the same information, so disagreements are about reasoning, not data access.
- A judge agent synthesizes the debate into an actionable verdict without requiring a human bottleneck for every decision.
- This pattern is especially powerful for hallucination detection, threat modeling, factual verification, and design reviews.
- Secure tool execution and robust logging are essential when running adversarial agents in production.
---
## What's next
- [5.1 MCP Integration](../mcp-integration/README.md)
- [5.8 Security](../mcp-security/README.md)
- [5.5 Routing](../mcp-routing/README.md)