Used for feedback · quests · score card
Build an AI agent that discovers tools dynamically, talks to an LLM, and orchestrates the tool-calling loop.
Connect to OpenAI with function calling
Load tools dynamically from MCP server
User → LLM → Tool → LLM → Response
The agent needs an LLM to think. Everything here speaks the OpenAI wire format, so the provider is a line in .env and nothing more.
OPENAI_API_KEYOPENAI_API_KEY=your-gemini-api-key-here OPENAI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ OPENAI_MODEL=gemini-3.5-flash-lite
OPENAI_API_KEY=sk-... # OPENAI_BASE_URL not needed for OpenAI OPENAI_MODEL=gpt-5-mini
All paths — including Google ADK — use the same OPENAI_API_KEY from above.
ADK connects to OpenAI-compatible endpoints via LiteLlm, so no separate Google key is needed.
The agent is an HTTP service that accepts user queries and returns AI-generated responses. Start with the API surface.
from fastapi import FastAPI from pydantic import BaseModel import os app = FastAPI(title="AI Agent") class QueryRequest(BaseModel): query: str session_id: str = "default" @app.get("/health") async def health(): return {"status": "ok", "service": "agent"} @app.post("/query") async def handle_query(request: QueryRequest): result = await process_query(request.query) return {"response": result}
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "agent" })); app.MapPost("/query", async (QueryRequest req) => { var result = await ProcessQuery(req.Query); return Results.Ok(new { response = result }); }); record QueryRequest(string Query, string SessionId = "default"); app.Run("http://0.0.0.0:8001");
import { Hono } from 'hono'; import { serve } from '@hono/node-server'; const app = new Hono(); app.get('/health', (c) => c.json({ status: 'ok', service: 'agent' })); app.post('/query', async (c) => { const { query } = await c.req.json(); const result = await processQuery(query); return c.json({ response: result }); }); serve({ fetch: app.fetch, port: 8001 });
func main() { r := chi.NewRouter() r.Get("/health", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "service": "agent", }) }) r.Post("/query", func(w http.ResponseWriter, r *http.Request) { var req struct { Query string `json:"query"` } json.NewDecoder(r.Body).Decode(&req) result := processQuery(req.Query) json.NewEncoder(w).Encode(map[string]string{"response": result}) }) http.ListenAndServe(":8001", r) }
package no.javazone.agent; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController class AgentController { private final AgentService agent; AgentController(AgentService agent) { this.agent = agent; } // tools_loaded is worth exposing: it tells you whether a bad answer came // from the model or from the agent never finding the MCP server. @GetMapping("/health") Map<String, Object> health() { return Map.of("status", "ok", "service", "agent", "tools_loaded", agent.toolCount()); } @PostMapping("/query") ResponseEntity<QueryResponse> query(@RequestBody QueryRequest request) { if (request.query() == null || request.query().isBlank()) { return ResponseEntity.badRequest().body(new QueryResponse(null, "Query is required")); } try { return ResponseEntity.ok(new QueryResponse(agent.processQuery(request.query()), null)); } catch (RuntimeException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new QueryResponse(null, e.getMessage())); } } record QueryRequest(String query) {} record QueryResponse(String response, String error) {} }
from fastapi import FastAPI from pydantic import BaseModel import os app = FastAPI(title="AI Agent") class QueryRequest(BaseModel): query: str session_id: str = "default" @app.get("/health") async def health(): return {"status": "ok", "service": "agent"} @app.post("/query") async def handle_query(request: QueryRequest): result = await process_query(request.query, request.session_id) return {"response": result}
Set up the OpenAI client. The model name comes from an environment variable so you can easily switch between OpenAI and local models (Ollama) later.
from openai import AsyncOpenAI client = AsyncOpenAI( api_key=os.environ.get("OPENAI_API_KEY"), base_url=os.environ.get("OPENAI_BASE_URL"), # Optional: Gemini, Ollama, or any OpenAI-compatible endpoint ) MODEL = os.environ.get("OPENAI_MODEL", "gemini-3.5-flash-lite")
using OpenAI; using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!; var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gemini-3.5-flash-lite"; var openai = new ChatClient(model, apiKey);
import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL, }); const MODEL = process.env.OPENAI_MODEL ?? 'gemini-3.5-flash-lite';
import openai "github.com/sashabaranov/go-openai" var ( aiClient *openai.Client model = getEnv("OPENAI_MODEL", "gemini-3.5-flash-lite") ) func init() { config := openai.DefaultConfig(os.Getenv("OPENAI_API_KEY")) if baseURL := os.Getenv("OPENAI_BASE_URL"); baseURL != "" { config.BaseURL = baseURL } aiClient = openai.NewClientWithConfig(config) }
package no.javazone.agent; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service class AgentService { private final McpClient mcp; private final OpenAIClient openai; private final String model; AgentService( McpClient mcp, @Value("${OPENAI_API_KEY:}") String apiKey, @Value("${OPENAI_BASE_URL:https://api.openai.com/v1}") String baseUrl, @Value("${OPENAI_MODEL:gemini-3.5-flash-lite}") String model) { // Fail at startup, not on the first request. A missing key is a config // problem, and you want to find it when the container starts. if (apiKey.isBlank()) { throw new IllegalStateException("OPENAI_API_KEY environment variable is required"); } this.mcp = mcp; this.model = model; // baseUrl is overridable so the same code runs against Gemini, // Azure OpenAI, or a local Ollama endpoint. this.openai = OpenAIOkHttpClient.builder() .apiKey(apiKey) .baseUrl(baseUrl) .build(); } }
from google.adk.agents import LlmAgent from google.adk.models.lite_llm import LiteLlm from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.genai import types # Reuse the same OPENAI_API_KEY from module 4 — ADK routes through LiteLlm _model_name = os.environ.get("OPENAI_MODEL", "gemini-3.5-flash-lite") _base_url = os.environ.get("OPENAI_BASE_URL") # None = OpenAI; set it for Gemini or Ollama MODEL = LiteLlm( model=f"openai/{_model_name}", api_base=_base_url, ) APP_NAME = "mcp-workshop" session_service = InMemorySessionService()
Same .env as every other path — OPENAI_API_KEY and optionally
OPENAI_BASE_URL for Gemini or Ollama.
LiteLlm is bundled with google-adk, no extra install needed.
On startup, the agent calls the MCP server's tools/list to discover
available tools. It then converts the MCP tool format to OpenAI's function calling format.
import httpx MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://mcp-server:8000") async def load_tools_from_mcp() -> list[dict]: """Discover tools from MCP server and convert to OpenAI format.""" async with httpx.AsyncClient() as http: resp = await http.post( f"{MCP_SERVER_URL}/message", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"} ) mcp_tools = resp.json()["result"]["tools"] # Convert MCP format → OpenAI function calling format openai_tools = [] for tool in mcp_tools: openai_tools.append({ "type": "function", "function": { "name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"] } }) return openai_tools
async Task<List<ChatTool>> LoadToolsFromMcp() { var resp = await _http.PostAsJsonAsync($"{mcpUrl}/message", new { jsonrpc = "2.0", id = 1, method = "tools/list" }); var result = await resp.Content.ReadFromJsonAsync<JsonElement>(); var tools = result.GetProperty("result").GetProperty("tools"); return tools.EnumerateArray().Select(t => ChatTool.CreateFunctionTool( t.GetProperty("name").GetString()!, t.GetProperty("description").GetString()!, BinaryData.FromString(t.GetProperty("inputSchema").GetRawText()) )).ToList(); }
const MCP_URL = process.env.MCP_SERVER_URL ?? 'http://mcp-server:8000'; export async function loadToolsFromMcp() { const resp = await fetch(`${MCP_URL}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) }); const { result } = await resp.json(); return result.tools.map((t: any) => ({ type: 'function' as const, function: { name: t.name, description: t.description, parameters: t.inputSchema } })); }
func loadToolsFromMcp() ([]openai.Tool, error) { body, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", }) resp, err := http.Post(mcpURL+"/message", "application/json", bytes.NewReader(body)) // ... parse response, convert to openai.Tool format var tools []openai.Tool for _, t := range mcpTools { tools = append(tools, openai.Tool{ Type: openai.ToolTypeFunction, Function: &openai.FunctionDefinition{ Name: t.Name, Description: t.Description, Parameters: t.InputSchema, }, }) } return tools, nil }
// The MCP inputSchema is already a JSON Schema object, which is exactly what // OpenAI wants for function parameters. There is no mapping layer here on // purpose - that is what makes a new tool on the server side free. private volatile List<ChatCompletionFunctionTool> tools = List.of(); void loadTools() { var discovered = mcp.listTools(); var translated = new ArrayList<ChatCompletionFunctionTool>(discovered.size()); for (var tool : discovered) { var parameters = FunctionParameters.builder(); tool.inputSchema().forEach((key, value) -> parameters.putAdditionalProperty(key, JsonValue.from(value))); translated.add(ChatCompletionFunctionTool.builder() .function(FunctionDefinition.builder() .name(tool.name()) .description(tool.description()) .parameters(parameters.build()) .build()) .build()); } this.tools = List.copyOf(translated); log.info("Loaded {} tools from MCP server: {}", translated.size(), discovered.stream().map(McpClient.ToolDefinition::name).toList()); } // Discovery runs once the web server is up, with retries: in Compose the agent // regularly wins the startup race against the MCP server, and a single failed // attempt would otherwise leave it permanently toolless. @EventListener(ApplicationReadyEvent.class) void loadToolsWithRetry() throws InterruptedException { for (var attempt = 1; attempt <= 5; attempt++) { try { loadTools(); return; } catch (RuntimeException e) { log.warn("Attempt {}/5: could not load tools ({}), retrying in 3s", attempt, e.getMessage()); Thread.sleep(3000); } } log.error("Giving up on tool discovery. The agent will answer without tools."); }
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, SseServerParams MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://mcp-server:8000") async def load_adk_tools(): """Connect to MCP server via SSE and load tools as ADK tools.""" toolset = MCPToolset( connection_params=SseServerParams(url=f"{MCP_SERVER_URL}/sse") ) tools, _ = await toolset.load_tools() return tools
Requires SSE transport. If you built the MCP server with FastMCP (lab 04), change
mcp.run() to use
transport="sse" — FastMCP then exposes GET /sse automatically.
ADK's MCPToolset handles tool discovery and execution over that connection.
Key insight: The MCP format uses inputSchema while OpenAI
uses parameters. The conversion is just a field rename —
both use JSON Schema internally.
When the LLM decides to use a tool, the agent sends a tools/call
JSON-RPC request to the MCP server.
async def call_mcp_tool(tool_name: str, arguments: dict) -> str: """Call a tool on the MCP server via JSON-RPC.""" async with httpx.AsyncClient() as http: resp = await http.post( f"{MCP_SERVER_URL}/message", json={ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": tool_name, "arguments": arguments } } ) result = resp.json()["result"] # Extract text content for the LLM if result.get("isError"): return f"Tool error: {result['content'][0]['text']}" return result["content"][0]["text"]
async Task<string> CallMcpTool(string toolName, string argsJson) { var resp = await _http.PostAsJsonAsync($"{mcpUrl}/message", new { jsonrpc = "2.0", id = 1, method = "tools/call", @params = new { name = toolName, arguments = JsonSerializer.Deserialize<object>(argsJson) } }); var result = await resp.Content.ReadFromJsonAsync<JsonElement>(); return result.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString()!; }
export async function callMcpTool(name: string, args: Record<string, any>): Promise<string> { const resp = await fetch(`${MCP_URL}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }) }); const { result } = await resp.json(); return result.content[0].text; }
func callMcpTool(name string, args map[string]interface{}) (string, error) { body, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]interface{}{"name": name, "arguments": args}, }) resp, err := http.Post(mcpURL+"/message", "application/json", bytes.NewReader(body)) // ... parse result.content[0].text return text, nil }
package no.javazone.agent; // Nothing in this class knows what tools exist. That is the point of MCP: the // server answers tools/list, and the agent builds its tool set from the answer. @Component class McpClient { private final RestClient http = RestClient.create(); private final String baseUrl; McpClient(@Value("${MCP_SERVER_URL:http://mcp-server:8000}") String baseUrl) { this.baseUrl = baseUrl; } List<ToolDefinition> listTools() { var response = post(new JsonRpcRequest("2.0", 1, "tools/list", null), ToolsListResponse.class); return requireResult(response.result(), response.error()).tools(); } // We send content[].text back to the model, not structuredContent. The text // is already a summary the model can quote, and every language in this // workshop does the same - so you can swap implementations mid-exercise. // // A failed tool arrives as isError=true with the reason in the text. Pass it // through: the model gets to explain the failure instead of the request // dying here. String callTool(String name, Map<String, Object> arguments) { var request = new JsonRpcRequest("2.0", 2, "tools/call", Map.of("name", name, "arguments", arguments)); var result = requireResult(post(request, ToolCallResponse.class).result(), null); if (result.content() == null || result.content().isEmpty()) { return result.isError() ? "Tool returned an error with no message" : "No content returned"; } return result.content().stream() .filter(c -> "text".equals(c.type())) .map(Content::text) .reduce((a, b) -> a + "\n" + b) .orElse("No content returned"); } private static <T> T requireResult(T result, JsonRpcError error) { if (error != null) { throw new IllegalStateException("MCP error %d: %s".formatted(error.code(), error.message())); } if (result == null) { throw new IllegalStateException("MCP response had neither result nor error"); } return result; } record ToolDefinition(String name, String description, Map<String, Object> inputSchema) {} }
ADK handles tool execution automatically.
MCPToolset wraps each MCP tool so that when
LlmAgent decides to call a tool, ADK invokes it
over the SSE connection and returns the result to the model. No routing code needed — skip to Step 5e.
This is the heart of the agent. Send the user's query to the LLM with tool definitions. If the LLM wants to call a tool, execute it and send the result back. Repeat until the LLM produces a final text response.
import json async def process_query(query: str) -> str: # 1. Load available tools from MCP tools = await load_tools_from_mcp() # 2. Start conversation with user's query messages = [{"role": "user", "content": query}] # 3. Agentic loop — keep going until no more tool calls while True: response = await client.chat.completions.create( model=MODEL, messages=messages, tools=tools if tools else None, ) choice = response.choices[0] message = choice.message # If no tool calls, we have our final answer if not message.tool_calls: return message.content # 4. Execute each tool call messages.append(message.model_dump()) for tool_call in message.tool_calls: args = json.loads(tool_call.function.arguments) result = await call_mcp_tool(tool_call.function.name, args) # 5. Send tool result back to the LLM messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) # Loop continues — LLM sees the tool results and responds
async Task<string> ProcessQuery(string query) { var tools = await LoadToolsFromMcp(); var messages = new List<ChatMessage> { new UserChatMessage(query) }; while (true) { var options = new ChatCompletionOptions(); foreach (var t in tools) options.Tools.Add(t); var result = await openai.CompleteChatAsync(messages, options); var completion = result.Value; if (completion.FinishReason != ChatFinishReason.ToolCalls) return completion.Content[0].Text; messages.Add(new AssistantChatMessage(completion)); foreach (var tc in completion.ToolCalls) { var toolResult = await CallMcpTool(tc.FunctionName, tc.FunctionArguments.ToString()); messages.Add(new ToolChatMessage(tc.Id, toolResult)); } } }
async function processQuery(query: string): Promise<string> { const tools = await loadToolsFromMcp(); const messages: any[] = [{ role: 'user', content: query }]; while (true) { const response = await openai.chat.completions.create({ model: MODEL, messages, tools: tools.length ? tools : undefined, }); const message = response.choices[0].message; if (!message.tool_calls?.length) return message.content ?? ''; messages.push(message); for (const tc of message.tool_calls) { const args = JSON.parse(tc.function.arguments); const result = await callMcpTool(tc.function.name, args); messages.push({ role: 'tool', tool_call_id: tc.id, content: result }); } } }
func processQuery(query string) (string, error) { tools, _ := loadToolsFromMcp() messages := []openai.ChatCompletionMessage{ {Role: openai.ChatMessageRoleUser, Content: query}, } for { resp, err := aiClient.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{ Model: model, Messages: messages, Tools: tools, }) choice := resp.Choices[0] if len(choice.Message.ToolCalls) == 0 { return choice.Message.Content, nil } messages = append(messages, choice.Message) for _, tc := range choice.Message.ToolCalls { var args map[string]interface{} json.Unmarshal([]byte(tc.Function.Arguments), &args) result, _ := callMcpTool(tc.Function.Name, args) messages = append(messages, openai.ChatCompletionMessage{ Role: openai.ChatMessageRoleTool, ToolCallID: tc.ID, Content: result, }) } } }
// Ask the model. If it answers, we are done. If it asks for tools instead, run // them through MCP, append the results, and ask again. // // MAX_ROUNDS is not decoration: a model that keeps calling the same tool will // loop forever without it, and you pay for every round. private static final int MAX_ROUNDS = 10; String processQuery(String query) { var builder = ChatCompletionCreateParams.builder() .model(model) .addSystemMessage(SYSTEM_PROMPT) .addUserMessage(query); tools.forEach(builder::addTool); for (var round = 0; round < MAX_ROUNDS; round++) { var completion = openai.chat().completions().create(builder.build()); var choices = completion.choices(); if (choices.isEmpty()) { throw new IllegalStateException("No choices in OpenAI response"); } var message = choices.getFirst().message(); var toolCalls = message.toolCalls().orElse(List.of()); // No tool calls means the model is done talking to us. if (toolCalls.isEmpty()) { return message.content().orElse("(No response generated)"); } // The assistant message has to go back in too, or the model loses track // of what it just asked for. builder.addMessage(message); for (var toolCall : toolCalls) { // toolCalls is a union that also covers custom tools. We only ever // register function tools, so anything else is not ours to run. var functionCall = toolCall.function().orElse(null); if (functionCall != null) { builder.addMessage(runTool(functionCall)); } } } throw new IllegalStateException("Agent loop exceeded " + MAX_ROUNDS + " iterations"); } private ChatCompletionToolMessageParam runTool(ChatCompletionMessageFunctionToolCall toolCall) { var function = toolCall.function(); var name = function.name(); String result; try { var arguments = function.arguments().isBlank() ? Map.<String, Object>of() : json.readValue(function.arguments(), new TypeReference<Map<String, Object>>() {}); result = mcp.callTool(name, arguments); } catch (Exception e) { // A tool failure is information for the model, not a dead request. result = "Error calling tool %s: %s".formatted(name, e.getMessage()); } return ChatCompletionToolMessageParam.builder() .toolCallId(toolCall.id()) .content(result) .build(); }
async def process_query(query: str, session_id: str = "default") -> str: """Run a query through the ADK agent with MCP tools.""" tools = await load_adk_tools() agent = LlmAgent( name="weather-agent", model=MODEL, # LiteLlm instance — uses OPENAI_API_KEY under the hood tools=tools, instruction="You are a helpful assistant. Use available tools to answer questions.", ) runner = Runner( agent=agent, app_name=APP_NAME, session_service=session_service, ) session = await session_service.create_session( app_name=APP_NAME, user_id=session_id, ) content = types.Content( role="user", parts=[types.Part(text=query)] ) final_response = "" async for event in runner.run_async( user_id=session_id, session_id=session.id, new_message=content, ): if event.is_final_response(): final_response = event.content.parts[0].text return final_response
Important: The while True loop is essential.
The LLM might need to call multiple tools before giving a final answer. Each iteration:
(1) ask the LLM, (2) if it wants tools, execute them, (3) feed results back, (4) repeat.
Package the agent into a Docker container. Same pattern as the MCP server.
FROM python:3.14-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8001 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
fastapi>=0.104.0 uvicorn[standard]>=0.24.0 httpx>=0.27.0 openai>=1.42.0 pydantic>=2.0.0
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app . EXPOSE 8001 ENTRYPOINT ["dotnet", "AgentService.dll"]
FROM node:26-alpine WORKDIR /app COPY package*.json . RUN npm ci COPY . . RUN npx tsc EXPOSE 8001 CMD ["node", "dist/index.js"]
FROM golang:1.27 AS build WORKDIR /src COPY go.* . RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/agent . FROM gcr.io/distroless/static COPY --from=build /app/agent /agent EXPOSE 8001 ENTRYPOINT ["/agent"]
FROM maven:3-eclipse-temurin-25 AS builder WORKDIR /build # Dependencies first, so a code-only change does not re-download the world. COPY pom.xml . RUN mvn -B -q dependency:go-offline COPY src ./src RUN mvn -B -q package -DskipTests FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=builder /build/target/agent-1.0.0.jar app.jar EXPOSE 8001 ENV PORT=8001 ENTRYPOINT ["java", "-jar", "app.jar"]
FROM python:3.14-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8001 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
fastapi>=0.104.0 uvicorn[standard]>=0.24.0 google-adk>=1.0.0
Your feedback helps us improve the workshop.
Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.