Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions docs/docs/using/agents/a2a.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,42 @@ Associate A2A agents with virtual servers to:

### Demo A2A Agent

The repository includes a demo A2A agent with calculator and weather tools for local testing:
The repository includes a demo A2A agent with calculator and weather tools for local testing.

#### Prerequisites

Before running the demo agent, ensure the following configuration:

1. **Allow localhost in .env** (required for local agent registration)

```bash
SSRF_ALLOW_LOCALHOST=true
```

Restart ContextForge after adding this. The default blocks loopback addresses as an SSRF safeguard. This is intentional for production, but must be opted into locally.

2. **Pass your admin email at runtime**

The script creates a JWT signed with your instance's secret. The token subject must match a user in the database (typically your `PLATFORM_ADMIN_EMAIL`). See the "Running the Demo" section below for the actual commands.

#### Running the Demo

```bash
# Terminal 1: Start ContextForge
make dev

# Terminal 2: Start the demo agent (auto-registers with ContextForge)
# Override the token subject if your admin email differs from the default:
# export PLATFORM_ADMIN_EMAIL=you@example.com
uv run python scripts/demo_a2a_agent.py

# Optional: Generate a token for the curl test commands below
export TOKEN=$(python -m mcpgateway.utils.create_jwt_token \
--username "admin@example.com" --exp 60)
```

Note: The script reads `JWT_SECRET_KEY` and `PLATFORM_ADMIN_EMAIL` from environment variables (defaults: `my-test-key…` and `admin@example.com`).

The demo agent supports these query formats:

| Query | Example | Response |
Expand All @@ -249,22 +275,22 @@ The demo agent supports these query formats:

1. Go to `http://localhost:8000/admin`
2. Click the "A2A Agents" tab
3. Find "Demo Calculator Agent" and click **Test**
4. Enter a query like `calc: 100/4+25` in the modal
5. Click **Run Test** to see the result
3. Add a new agent "demo-calculator-agent" with Endpoint URL of http://localhost:9100/run
4. In "demo-calculator-agent" click on **Test**
5. Enter a query like `calc: 100/4+25` in the modal
6. Click **Run Test** to see the result

**Test via API:**

```bash
# Get a token
export TOKEN=$(python3 -m mcpgateway.utils.create_jwt_token \
--username admin@example.com --exp 60 --secret my-test-key-but-now-longer-than-32-bytes)
--username admin@example.com --exp 60)

# Invoke the agent
curl -X POST "http://localhost:8000/a2a/demo-calculator-agent/invoke" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "calc: 15*4+10"}'
-d '{"parameters": {"query": "calc: 15*4+10"}}'
```

### A2A SDK HelloWorld Sample
Expand Down
87 changes: 49 additions & 38 deletions scripts/demo_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
"""

import atexit
import json
import os
import random
import signal
import socket
import sys
from contextlib import closing

import httpx
import jwt
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from pydantic import BaseModel

# ============================================================================
Expand Down Expand Up @@ -121,53 +121,64 @@ def run(self, query: str) -> str:
agent = SimpleAgent("Demo-A2A-Agent")


class Parameters(BaseModel):
"""Parameters object containing the actual query."""

query: str = ""
message: str = ""


class A2ARequest(BaseModel):
"""Request model for A2A protocol format.

ContextForge sends custom agents requests in this format:
{
"interaction_type": "admin_test",
"parameters": {"query": "weather: Dallas", "message": "..."},
"protocol_version": "1.0"
}
"""

interaction_type: str = ""
parameters: Parameters | None = None
protocol_version: str = ""
# Also support direct query/message for simple testing
query: str = ""
message: str = ""


class Response(BaseModel):
"""Response model for agent results."""

response: str


@app.post("/run")
def run_agent(req: A2ARequest) -> Response:
async def run_agent(request: Request) -> Response:
"""Execute a query against the agent.

Supports both:
- A2A protocol format: {"parameters": {"query": "..."}}
- Simple format: {"query": "..."}
Supports multiple formats:
- JSONRPC: {"jsonrpc": "2.0", "method": "...", "params": {"query": "..."}}
- A2A protocol: {"parameters": {"query": "..."}}
- Simple: {"query": "..."}
"""
# Extract query from A2A protocol format (parameters.query)
# or fall back to direct query/message fields
# Parse request body
body = await request.body()
try:
body_dict = json.loads(body)
except (json.JSONDecodeError, ValueError):
return Response(response="Error: invalid JSON in request body")

query_text = ""
if req.parameters:
query_text = req.parameters.query or req.parameters.message

# Handle JSONRPC format (ContextForge sends this for agents with URLs ending in /)
if "jsonrpc" in body_dict:
params = body_dict.get("params", {})

# Handle nested message structure from Admin UI test
if "message" in params and isinstance(params["message"], dict):
message_obj = params["message"]
# Extract text from parts array
if "parts" in message_obj and isinstance(message_obj["parts"], list):
for part in message_obj["parts"]:
if isinstance(part, dict) and part.get("kind") == "text":
query_text = part.get("text", "")
break
print("Extracted query from JSONRPC message.parts")
else:
# Simple query or message string
query_text = params.get("query") or params.get("message", "")
print("Extracted query from JSONRPC params")
# Handle A2A protocol format
elif "parameters" in body_dict and isinstance(body_dict["parameters"], dict):
params = body_dict["parameters"]
query_text = params.get("query") or params.get("message", "")
print("Extracted query from A2A parameters")
# Handle simple format
elif "query" in body_dict:
query_text = body_dict["query"]
print("Extracted query from query field")
elif "message" in body_dict:
query_text = body_dict["message"]
print("Extracted query from message field")

if not query_text:
query_text = req.query or req.message or "Hello"
query_text = "Hello"
print("No query found, using default: Hello")

response = agent.run(query_text)
return Response(response=response)
Expand All @@ -189,7 +200,7 @@ def health():
AGENT_ID = None


def create_jwt_token(username: str = "admin@example.com") -> str:
def create_jwt_token(username: str = os.environ.get("PLATFORM_ADMIN_EMAIL", "admin@example.com")) -> str:
"""Create a JWT token for ContextForge authentication."""
import datetime

Expand Down
12 changes: 6 additions & 6 deletions scripts/demo_a2a_agent_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,19 +334,19 @@ def run(self, query: str) -> str:
class Parameters(BaseModel):
"""Parameters object containing the actual query."""

query: str = ""
message: str = ""
query: Optional[str] = None
message: Optional[str] = None


class A2ARequest(BaseModel):
"""Request model for A2A protocol format (ContextForge custom agent format)."""

interaction_type: str = ""
interaction_type: Optional[str] = None
parameters: Optional[Parameters] = None
protocol_version: str = ""
protocol_version: Optional[str] = None
# Also support direct query/message for simple testing
query: str = ""
message: str = ""
query: Optional[str] = None
message: Optional[str] = None


class MessagePart(BaseModel):
Expand Down
Loading
Loading