Skip to content

Advanced: Custom Client and LLM Agent

Building the Loop That Powers Every AI Product

Introduction

Throughout this workshop VS Code Copilot acted as your MCP host. It connected to your servers, discovered the tools, and called them in response to natural language. That loop is not magic. In this chapter you will build it yourself, in two stages:

  1. client.js / Client.cs / client.py: a programmatic MCP client that connects to your servers and calls every tool you have built across the workshop, so you can see the full picture in one place.
  2. agent.js / Agent.cs / agent.py: a Gemini-powered agent that drives the same clients with a natural language loop: receive a prompt, discover tools, call the right ones, synthesise an answer.

By the end you will have a complete working agent and a clear mental model of what every AI coding assistant, chat interface, and automation tool is doing under the hood.

Prerequisites

Make sure you have completed Chapters 01–03. Your project folder should look like this:

text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── node_modules/
├── .env
├── aps-server.js
├── package.json
├── package-lock.json
└── server.js
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── ApsServer.cs
├── DevconWorkshop.csproj
└── Server.cs
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── .venv/
├── .env
├── aps-server.py
├── requirements.txt
└── server.py

Build the MCP Client

The client is a standalone program that connects directly to your MCP servers and calls tools programmatically, without VS Code or Copilot. Just like VS Code Copilot connects to multiple servers via mcp.json, your client connects to both the workshop server and the APS server over HTTP. This is useful for scripting, testing, and as the base for building the agent.

In Node.js, create a new file called client.js. In .NET, create a new file called Client.cs alongside the existing Server.cs and ApsServer.cs. In Python, create a new file called client.py.

text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── node_modules/
├── .env
├── aps-server.js
├── client.js         ← new
├── package.json
├── package-lock.json
└── server.js
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── ApsServer.cs
├── Client.cs           ← new
├── DevconWorkshop.csproj
└── Server.cs
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── .venv/
├── .env
├── aps-server.py
├── client.py          ← new
├── requirements.txt
└── server.py

.NET — Adding a third Main. Client.cs is the third entry-point in your project. You'll override <StartupObject> at runtime with dotnet run -p:StartupObject=DevconWorkshop.Client to pick it. No csproj change needed.

The Imports / Usings - What Each One Does

javascript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
csharp
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
python
import asyncio
from contextlib import AsyncExitStack

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult, TextContent

Node.js — what each import does

ImportWhat it does
ClientThe MCP client class. Manages the connection to the server and exposes methods like listTools() and callTool().
StreamableHTTPClientTransportConnects the client to a remote MCP server over HTTP. Sends JSON-RPC messages via POST and can receive streaming responses via SSE.

.NET — what each using does

UsingWhat it does
ModelContextProtocol.ClientMcpClient, HttpClientTransport, and HttpClientTransportOptions — the client-side surface of the MCP SDK.
ModelContextProtocol.ProtocolProtocol-level types such as Implementation (client identification) and TextContentBlock (extracting text from tool responses).

Python — what each import does

ImportWhat it does
asyncioRuns the async main() entry point with asyncio.run(...).
AsyncExitStackManages the lifetimes of both transport connections and sessions, closing them cleanly when the async with block exits.
ClientSessionThe low-level MCP client session. Exposes initialize(), list_tools(), and call_tool().
streamable_http_clientConnects the session to a remote MCP server over Streamable HTTP — the client-side counterpart of the server transport.
CallToolResult, TextContentTyped results used by the first_text() helper to pull the text out of a tool response.

Only the .NET client needs a new package — the client side of MCP lives in ModelContextProtocol, separate from the ModelContextProtocol.AspNetCore server package. The Node.js and Python clients need no new package: client and server both live in the same package already installed in Chapter 1 (@modelcontextprotocol/sdk for Node.js, mcp for Python).

bash
dotnet add package ModelContextProtocol --version 1.3.0

Section 1 - Connect to the servers

javascript
// Connect to the workshop server
const workshopClient = new Client({
  name: "devcon-workshop-client",
  version: "1.0.0",
});
await workshopClient.connect(
  new StreamableHTTPClientTransport(new URL("http://localhost:3000/mcp")),
);
console.log("Connected to workshop MCP server.");

// Connect to the APS server
const apsClient = new Client({
  name: "devcon-aps-client",
  version: "1.0.0",
});
await apsClient.connect(
  new StreamableHTTPClientTransport(new URL("http://localhost:3001/mcp")),
);
console.log("Connected to APS MCP server.");
csharp
// Connect to the workshop server
await using var workshopClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri("http://localhost:3000/mcp"),
        TransportMode = HttpTransportMode.StreamableHttp,
        Name = "devcon-workshop-client",
    }),
    new McpClientOptions
    {
        ClientInfo = new Implementation { Name = "devcon-workshop-client", Version = "1.0.0" }
    });
Console.WriteLine("Connected to workshop MCP server.");

// Connect to the APS server
await using var apsClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri("http://localhost:3001/mcp"),
        TransportMode = HttpTransportMode.StreamableHttp,
        Name = "devcon-aps-client",
    }),
    new McpClientOptions
    {
        ClientInfo = new Implementation { Name = "devcon-aps-client", Version = "1.0.0" }
    });
Console.WriteLine("Connected to APS MCP server.");
python
async def main() -> None:
    async with AsyncExitStack() as stack:
        # Connect to the workshop server
        workshop_read, workshop_write, *_ = await stack.enter_async_context(
            streamable_http_client("http://localhost:3000/mcp")
        )
        workshop = await stack.enter_async_context(
            ClientSession(workshop_read, workshop_write)
        )
        await workshop.initialize()
        print("Connected to workshop MCP server.")

        # Connect to the APS server
        aps_read, aps_write, *_ = await stack.enter_async_context(
            streamable_http_client("http://localhost:3001/mcp")
        )
        aps = await stack.enter_async_context(ClientSession(aps_read, aps_write))
        await aps.initialize()
        print("Connected to APS MCP server.")

Client.connect() (Node.js), McpClient.CreateAsync() (.NET), and session.initialize() (Python) trigger the MCP handshake: the client and server exchange names, versions, and supported capabilities. After this call the connection is ready. We connect to both servers independently, just like VS Code Copilot does.

  • Python — uses the lower-level ClientSession API. streamable_http_client(...) yields a read/write stream pair, and ClientSession wraps it. The AsyncExitStack owns both connections so they close cleanly when main() exits — the equivalent of .NET's await using.

Section 2 - Discover available tools

javascript
const { tools: workshopTools } = await workshopClient.listTools();
const { tools: apsTools } = await apsClient.listTools();
const allTools = [...workshopTools, ...apsTools];

console.log("\nAvailable tools:");
allTools.forEach((tool) => {
  console.log(`  - ${tool.name}: ${tool.description}`);
});
csharp
var workshopTools = await workshopClient.ListToolsAsync();
var apsTools = await apsClient.ListToolsAsync();
var allTools = workshopTools.Concat(apsTools).ToList();

Console.WriteLine("\nAvailable tools:");
foreach (var tool in allTools)
{
    Console.WriteLine($"  - {tool.Name}: {tool.Description}");
}
python
workshop_tools = (await workshop.list_tools()).tools
aps_tools = (await aps.list_tools()).tools
all_tools = [*workshop_tools, *aps_tools]

print("\nAvailable tools:")
for tool in all_tools:
    print(f"  - {tool.name}: {tool.description}")

listTools() / ListToolsAsync() / list_tools() asks each server to advertise everything it can do. We merge the results into a single list. This is exactly what VS Code Copilot does when it connects to multiple servers via mcp.json.

Section 3 - Call every tool from the workshop

Bucket names must be globally unique and contain only lowercase letters, numbers, and dashes. Replace the example names below with your own unique names.

javascript
// Chapter 01 - add
const addResult = await workshopClient.callTool({
  name: "add",
  arguments: { a: 12, b: 30 },
});
console.log("\nadd(12, 30):", addResult.content[0].text);

// Chapter 01 - greet
const greetResult = await workshopClient.callTool({
  name: "greet",
  arguments: { name: "Nabil", language: "spanish" },
});
console.log("greet():", greetResult.content[0].text);

// Chapter 02 - get_weather
const weatherResult = await workshopClient.callTool({
  name: "get_weather",
  arguments: { city: "Amsterdam" },
});
console.log("get_weather():", weatherResult.content[0].text);

// Chapter 03 - create_bucket then list_buckets (directly on APS server)
const createResult = await apsClient.callTool({
  name: "create_bucket",
  arguments: {
    bucket_key: "devcon-test-us",
    policy: "persistent",
    region: "US",
  },
});
console.log("\ncreate_bucket():\n", createResult.content[0].text);

const bucketsResult = await apsClient.callTool({
  name: "list_buckets",
  arguments: { region: "US" },
});
console.log("\nlist_buckets():\n", bucketsResult.content[0].text);

await workshopClient.close();
await apsClient.close();
csharp
// Chapter 01: add
var addResult = await workshopClient.CallToolAsync(
    "add",
    new Dictionary<string, object?> { ["a"] = 12, ["b"] = 30 });
Console.WriteLine($"\nadd(12, 30): {addResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}");

// Chapter 01: greet
var greetResult = await workshopClient.CallToolAsync(
    "greet",
    new Dictionary<string, object?> { ["name"] = "Nabil", ["language"] = "spanish" });
Console.WriteLine($"greet(): {greetResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}");

// Chapter 02: get_weather
var weatherResult = await workshopClient.CallToolAsync(
    "get_weather",
    new Dictionary<string, object?> { ["city"] = "Amsterdam" });
Console.WriteLine($"get_weather(): {weatherResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}");

// Chapter 03: create_bucket then list_buckets (directly on APS server)
var createResult = await apsClient.CallToolAsync(
    "create_bucket",
    new Dictionary<string, object?>
    {
        ["bucket_key"] = "devcon-test-us",
        ["policy"] = "persistent",
        ["region"] = "US",
    });
Console.WriteLine($"\ncreate_bucket():\n{createResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}");

var bucketsResult = await apsClient.CallToolAsync(
    "list_buckets",
    new Dictionary<string, object?> { ["region"] = "US" });
Console.WriteLine($"\nlist_buckets():\n{bucketsResult.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text}");
python
# Helper at module level: pull the text out of the first content block
def first_text(result: CallToolResult) -> str:
    block = result.content[0]
    return block.text if isinstance(block, TextContent) else str(block)


# Inside main(), after discovering tools:

# Chapter 01: add
add_result = await workshop.call_tool("add", arguments={"a": 12, "b": 30})
print(f"\nadd(12, 30): {first_text(add_result)}")

# Chapter 01: greet
greet_result = await workshop.call_tool(
    "greet", arguments={"name": "Nabil", "language": "spanish"}
)
print(f"greet(): {first_text(greet_result)}")

# Chapter 02: get_weather
weather_result = await workshop.call_tool(
    "get_weather", arguments={"city": "Amsterdam"}
)
print(f"get_weather(): {first_text(weather_result)}")

# Chapter 03: create_bucket then list_buckets (directly on APS server)
create_result = await aps.call_tool(
    "create_bucket",
    arguments={
        "bucket_key": "devcon-test-us",
        "policy": "persistent",
        "region": "US",
    },
)
print(f"\ncreate_bucket():\n{first_text(create_result)}")

buckets_result = await aps.call_tool("list_buckets", arguments={"region": "US"})
print(f"\nlist_buckets():\n{first_text(buckets_result)}")
  • Node.jsresult.content[0].text reads the first text block from the response.
  • .NETContent.OfType<TextContentBlock>().FirstOrDefault()?.Text is the equivalent: filter to text blocks, take the first, read its Text. The await using declarations at the top of Main handle closing the clients automatically.
  • Python — the first_text() helper reads the first content block and returns its .text when it is a TextContent. The AsyncExitStack closes both sessions automatically when main() exits — no explicit close() calls needed.

Run It

Start the servers, then run the client:

bash
# Terminal 1
node aps-server.js

# Terminal 2
node server.js

# Terminal 3
node client.js
bash
# Terminal 1 - APS server (default StartupObject)
dotnet run

# Terminal 2 - workshop server
dotnet run -p:StartupObject=DevconWorkshop.Server

# Terminal 3 - client
dotnet run -p:StartupObject=DevconWorkshop.Client
bash
# Terminal 1
python aps-server.py

# Terminal 2
python server.py

# Terminal 3
python client.py

View complete client.js in Source Code → · View complete Client.cs in Source Code → · View complete client.py in Source Code →


Get a Free Gemini API Key

Gemini's free tier is available through Google AI Studio. No credit card required, just a Google account.

  1. Go to aistudio.google.com
  2. Sign in with your Google account
  3. Click Get API keyCreate API key
  4. Copy the key

Store it next to your APS credentials — .env for Node.js and Python, User Secrets for .NET:

bash
GEMINI_API_KEY="your-key-here"
bash
dotnet user-secrets set "GEMINI_API_KEY" "your-key-here"
bash
GEMINI_API_KEY="your-key-here"

The Gemini 2.5 Flash (gemini-2.5-flash) free tier gives you 5 requests per minute and 20 requests per day, more than enough for this workshop.

Install the Gemini SDK

bash
npm install @google/genai
bash
dotnet add package Google.GenAI --version 1.7.0
bash
pip install "google-genai>=2.0.0"

🔗 @google/genai on npm · Google.GenAI on NuGet · google-genai on PyPI — Google's official SDKs for Gemini. All support function calling.

Build the Agent

Create a new file called agent.js (Node.js), Agent.cs (.NET), or agent.py (Python). This replaces the hardcoded callTool() calls in the client with a natural language loop driven by Gemini.

text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── node_modules/
├── .env
├── agent.js          ← new
├── aps-server.js
├── client.js
├── package.json
├── package-lock.json
└── server.js
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── Agent.cs            ← new
├── ApsServer.cs
├── Client.cs
├── DevconWorkshop.csproj
└── Server.cs
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── .venv/
├── .env
├── agent.py           ← new
├── aps-server.py
├── client.py
├── requirements.txt
└── server.py

The Imports / Usings - What Each One Does

javascript
import { GoogleGenAI } from "@google/genai";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import "dotenv/config";
csharp
using System.Text.Json;
using Google.GenAI;
using Google.GenAI.Types;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Tool = Google.GenAI.Types.Tool;
using GenAIClient = Google.GenAI.Client;
python
import asyncio
import os
from contextlib import AsyncExitStack

from dotenv import load_dotenv
from google import genai
from google.genai import types
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

Node.js — what each import does

ImportWhat it does
GoogleGenAIThe Gemini client. Sends prompts to the model and receives responses, including functionCall objects when the model wants to use a tool.
ClientThe MCP client, same as in client.js. Connects to your MCP servers and calls tools on behalf of the LLM.
StreamableHTTPClientTransportConnects the MCP client to your servers over HTTP.

.NET — what each using does

UsingWhat it does
Google.GenAI + Google.GenAI.TypesGemini's .NET SDK — Client, Content, Part, FunctionDeclaration, Schema, Tool.
ModelContextProtocol.Client + .ProtocolThe MCP client surface, same as in Client.cs.
using Tool = ... and using GenAIClient = ...Aliases to disambiguate Gemini's Tool and Client types from MCP's matching names in the same file.
System.Text.JsonConverts the MCP tool's JSON schema into Gemini's Schema type, and serialises function-call arguments.

Python — what each import does

ImportWhat it does
asyncioRuns the async main() loop and offloads blocking input() to a thread in the REPL challenge.
osReads GEMINI_API_KEY from the environment.
AsyncExitStackManages the two MCP session lifetimes, same as in client.py.
load_dotenv (python-dotenv)Loads GEMINI_API_KEY from .env.
genai, typesGemini's Python SDK — genai.Client plus the types.Tool, types.FunctionDeclaration, types.Content, and types.Part building blocks.
ClientSession, streamable_http_clientThe MCP client surface, same as in client.py. Connects to your servers and calls tools for the LLM.

Section 1 - Connect to the MCP Servers and Discover Tools

javascript
// Connect to both MCP servers
const workshopClient = new Client({
  name: "devcon-agent-workshop",
  version: "1.0.0",
});
await workshopClient.connect(
  new StreamableHTTPClientTransport(new URL("http://localhost:3000/mcp")),
);

const apsClient = new Client({ name: "devcon-agent-aps", version: "1.0.0" });
await apsClient.connect(
  new StreamableHTTPClientTransport(new URL("http://localhost:3001/mcp")),
);

// Merge tools from both servers
const { tools: workshopTools } = await workshopClient.listTools();
const { tools: apsTools } = await apsClient.listTools();
const allTools = [...workshopTools, ...apsTools];

// Build a lookup map: tool name → MCP client
const toolClientMap = {};
workshopTools.forEach((t) => (toolClientMap[t.name] = workshopClient));
apsTools.forEach((t) => (toolClientMap[t.name] = apsClient));

console.log(`Connected. ${allTools.length} tools available:`);
allTools.forEach((t) => console.log(`  - ${t.name}: ${t.description}`));
csharp
// Connect to both MCP servers
await using var workshopClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri("http://localhost:3000/mcp"),
        TransportMode = HttpTransportMode.StreamableHttp,
        Name = "devcon-agent-workshop",
    }),
    new McpClientOptions
    {
        ClientInfo = new Implementation { Name = "devcon-agent-workshop", Version = "1.0.0" }
    });

await using var apsClient = await McpClient.CreateAsync(
    new HttpClientTransport(new HttpClientTransportOptions
    {
        Endpoint = new Uri("http://localhost:3001/mcp"),
        TransportMode = HttpTransportMode.StreamableHttp,
        Name = "devcon-agent-aps",
    }),
    new McpClientOptions
    {
        ClientInfo = new Implementation { Name = "devcon-agent-aps", Version = "1.0.0" }
    });

// Merge tools from both servers
var workshopTools = await workshopClient.ListToolsAsync();
var apsTools = await apsClient.ListToolsAsync();
var allTools = workshopTools.Concat(apsTools).ToList();

// Build a lookup map: tool name → MCP client
var toolClientMap = new Dictionary<string, McpClient>();
foreach (var t in workshopTools) toolClientMap[t.Name] = workshopClient;
foreach (var t in apsTools) toolClientMap[t.Name] = apsClient;

Console.WriteLine($"Connected. {allTools.Count} tools available:");
foreach (var t in allTools)
{
    Console.WriteLine($"  - {t.Name}: {t.Description}");
}
python
async def main() -> None:
    async with AsyncExitStack() as stack:
        # Connect to the workshop server
        workshop_read, workshop_write, *_ = await stack.enter_async_context(
            streamable_http_client("http://localhost:3000/mcp")
        )
        workshop = await stack.enter_async_context(
            ClientSession(workshop_read, workshop_write)
        )
        await workshop.initialize()

        # Connect to the APS server
        aps_read, aps_write, *_ = await stack.enter_async_context(
            streamable_http_client("http://localhost:3001/mcp")
        )
        aps = await stack.enter_async_context(ClientSession(aps_read, aps_write))
        await aps.initialize()

        # Merge tools from both servers
        workshop_tools = (await workshop.list_tools()).tools
        aps_tools = (await aps.list_tools()).tools
        all_tools = [*workshop_tools, *aps_tools]

        # Build a lookup map: tool name → MCP client session
        tool_client_map: dict[str, ClientSession] = {}
        for t in workshop_tools:
            tool_client_map[t.name] = workshop
        for t in aps_tools:
            tool_client_map[t.name] = aps

        print(f"Connected. {len(all_tools)} tools available:")
        for t in all_tools:
            print(f"  - {t.name}: {t.description}")

Just like the standalone client, the agent connects to both servers and merges the tool lists. The toolClientMap lets us route each tool call to the correct server. The agent loop uses it to dispatch calls.

Section 2 - Convert MCP Tools to Gemini Function Declarations

Gemini's function calling API expects tools in a specific JSON schema format. We convert from MCP's inputSchema format to Gemini's functionDeclarations format:

javascript
const geminiTools = [
  {
    functionDeclarations: allTools.map((tool) => ({
      name: tool.name,
      description: tool.description,
      parameters: tool.inputSchema,
    })),
  },
];
csharp
var geminiTools = new List<Tool>
{
    new Tool
    {
        FunctionDeclarations = allTools.Select(t => new FunctionDeclaration
        {
            Name = t.Name,
            Description = t.Description,
            Parameters = ConvertSchema(t.JsonSchema),
        }).ToList()
    }
};

// Helper: convert MCP's JsonElement schema to Gemini's strongly-typed Schema
static Schema? ConvertSchema(JsonElement? element)
{
    if (element == null || element.Value.ValueKind != JsonValueKind.Object)
        return null;

    var el = element.Value;
    var schema = new Schema();

    if (el.TryGetProperty("type", out var typeProp) && typeProp.ValueKind == JsonValueKind.String)
    {
        schema.Type = typeProp.GetString() switch
        {
            "object" => Google.GenAI.Types.Type.Object,
            "string" => Google.GenAI.Types.Type.String,
            "number" => Google.GenAI.Types.Type.Number,
            "integer" => Google.GenAI.Types.Type.Integer,
            "boolean" => Google.GenAI.Types.Type.Boolean,
            "array" => Google.GenAI.Types.Type.Array,
            _ => (Google.GenAI.Types.Type?)null,
        };
    }

    if (el.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String)
        schema.Description = descProp.GetString();

    if (el.TryGetProperty("enum", out var enumProp) && enumProp.ValueKind == JsonValueKind.Array)
        schema.Enum = enumProp.EnumerateArray()
            .Where(e => e.ValueKind == JsonValueKind.String)
            .Select(e => e.GetString()!)
            .ToList();

    if (el.TryGetProperty("properties", out var propsProp) && propsProp.ValueKind == JsonValueKind.Object)
    {
        var properties = new Dictionary<string, Schema>();
        foreach (var prop in propsProp.EnumerateObject())
        {
            var childSchema = ConvertSchema(prop.Value);
            if (childSchema != null) properties[prop.Name] = childSchema;
        }
        schema.Properties = properties;
    }

    if (el.TryGetProperty("required", out var requiredProp) && requiredProp.ValueKind == JsonValueKind.Array)
        schema.Required = requiredProp.EnumerateArray()
            .Where(e => e.ValueKind == JsonValueKind.String)
            .Select(e => e.GetString()!)
            .ToList();

    if (el.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Object)
        schema.Items = ConvertSchema(itemsProp);

    return schema;
}
python
gemini_tools: types.ToolListUnion = [
    types.Tool(
        function_declarations=[
            types.FunctionDeclaration(
                name=t.name,
                description=t.description,
                parameters_json_schema=t.inputSchema,
            )
            for t in all_tools
        ]
    )
]

This is the bridge between MCP and the LLM. The tool names and descriptions your MCP server advertises become the function names and descriptions that Gemini uses to decide which tool to call. The quality of your tool descriptions directly affects how well the LLM chooses.

  • Node.jstool.inputSchema is already a plain JS object in the JSON Schema shape Gemini expects, so it passes through verbatim.
  • .NETt.JsonSchema is a JsonElement, while Gemini expects a strongly-typed Schema object. ConvertSchema walks the JsonElement and produces an equivalent Schema, handling type, description, enum, properties, required, and items.
  • Pythontypes.FunctionDeclaration accepts the raw JSON schema directly via parameters_json_schema=t.inputSchema, so — like Node.js — no manual conversion is needed.

Section 3 - The Tool-Calling Loop

javascript
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

async function ask(userPrompt) {
  console.log(`\n${"=".repeat(60)}`);
  console.log(`User: ${userPrompt}\n`);

  const messages = [{ role: "user", parts: [{ text: userPrompt }] }];

  while (true) {
    const response = await ai.models.generateContent({
      model: "gemini-2.5-flash",
      contents: messages,
      config: { tools: geminiTools, temperature: 0 },
    });

    const candidate = response.candidates[0].content;
    const toolCallParts = candidate.parts.filter((p) => p.functionCall);

    if (toolCallParts.length === 0) {
      const finalText = candidate.parts.map((p) => p.text || "").join("");
      console.log(`Agent: ${finalText}`);
      return finalText;
    }

    const toolResults = [];
    for (const part of toolCallParts) {
      const { name, args } = part.functionCall;
      console.log(`  → Calling tool: ${name}(${JSON.stringify(args)})`);

      const result = await toolClientMap[name].callTool({
        name,
        arguments: args,
      });
      const resultText = result.content.map((c) => c.text || "").join("\n");
      console.log(`  ← Result: ${resultText.slice(0, 120)}...`);

      toolResults.push({
        functionResponse: {
          name,
          response: { output: resultText },
        },
      });
    }

    messages.push({ role: "model", parts: candidate.parts }); // messages are cumulative - we keep adding to the conversation
    messages.push({ role: "user", parts: toolResults });
  }
}
csharp
var apiKey = System.Environment.GetEnvironmentVariable("GEMINI_API_KEY")
    ?? throw new InvalidOperationException("GEMINI_API_KEY environment variable is not set.");
var gemini = new GenAIClient(apiKey: apiKey);

static async Task Ask(
    GenAIClient gemini,
    List<Tool> tools,
    Dictionary<string, McpClient> toolClientMap,
    string userPrompt)
{
    Console.WriteLine();
    Console.WriteLine(new string('=', 60));
    Console.WriteLine($"User: {userPrompt}\n");

    var messages = new List<Content>
    {
        new Content { Role = "user", Parts = new List<Part> { new Part { Text = userPrompt } } }
    };

    while (true)
    {
        var response = await gemini.Models.GenerateContentAsync(
            "gemini-2.5-flash",
            messages,
            new GenerateContentConfig { Tools = tools, Temperature = 0 });

        var candidate = response.Candidates![0].Content!;
        var parts = candidate.Parts ?? new List<Part>();
        var toolCallParts = parts.Where(p => p.FunctionCall != null).ToList();

        if (toolCallParts.Count == 0)
        {
            var finalText = string.Concat(parts.Select(p => p.Text ?? ""));
            Console.WriteLine($"Agent: {finalText}");
            return;
        }

        var toolResults = new List<Part>();
        foreach (var part in toolCallParts)
        {
            var name = part.FunctionCall!.Name!;
            var fnArgs = part.FunctionCall.Args ?? new Dictionary<string, object>();
            Console.WriteLine($"  → Calling tool: {name}({JsonSerializer.Serialize(fnArgs)})");

            var mcpArgs = fnArgs.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);
            var result = await toolClientMap[name].CallToolAsync(name, mcpArgs);
            var resultText = string.Join("\n",
                result.Content.OfType<TextContentBlock>().Select(c => c.Text ?? ""));
            Console.WriteLine($"  ← Result: {resultText[..Math.Min(120, resultText.Length)]}...");

            toolResults.Add(new Part
            {
                FunctionResponse = new FunctionResponse
                {
                    Name = name,
                    Response = new Dictionary<string, object> { ["output"] = resultText },
                }
            });
        }

        messages.Add(new Content { Role = "model", Parts = parts });
        messages.Add(new Content { Role = "user", Parts = toolResults });
    }
}
python
gemini_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
config = types.GenerateContentConfig(tools=gemini_tools, temperature=0)

async def ask(user_prompt: str) -> None:
    print()
    print("=" * 60)
    print(f"User: {user_prompt}\n")

    messages: list[types.Content] = [
        types.Content(
            role="user",
            parts=[types.Part.from_text(text=user_prompt)],
        )
    ]

    while True:
        response = await gemini_client.aio.models.generate_content(
            model="gemini-2.5-flash",
            contents=messages,
            config=config,
        )

        content = response.candidates[0].content if response.candidates else None
        parts = content.parts if content and content.parts else []
        calls = [p.function_call for p in parts if p.function_call]

        if not calls or content is None:
            final_text = "".join(p.text or "" for p in parts)
            print(f"Agent: {final_text}")
            return

        messages.append(content)  # messages are cumulative - keep adding to the conversation

        tool_result_parts: list[types.Part] = []
        for fc in calls:
            if not fc.name:
                continue
            args = dict(fc.args or {})
            print(f"  → Calling tool: {fc.name}({args})")

            result = await tool_client_map[fc.name].call_tool(
                fc.name, arguments=args
            )
            result_text = "\n".join(
                getattr(c, "text", "") or "" for c in result.content
            )
            print(f"  ← Result: {result_text[:120]}...")

            tool_result_parts.append(
                types.Part.from_function_response(
                    name=fc.name,
                    response={"output": result_text},
                )
            )

        messages.append(types.Content(role="user", parts=tool_result_parts))

The loop works like a conversation:

  1. We send the user prompt + available tools to Gemini
  2. Gemini either answers or says "I need to call tool X with arguments Y"
  3. We execute the tool call via MCP and add the result back to the conversation
  4. Repeat until Gemini gives a final text answer with no more tool calls

This is exactly what happens inside Claude, ChatGPT, and VS Code Copilot when they use tools. The loop is always there, whether the framework hides it or not.

Section 4 - Ask Questions in Natural Language

Bucket names must be globally unique. Replace devcon-test2 with a unique name using only lowercase letters, numbers, and dashes.

javascript
await ask(
  "Create a new OSS bucket called 'devcon-test2' with a persistent policy in the US region, then list my US buckets to confirm it was created.",
);

await workshopClient.close();
await apsClient.close();
csharp
await Ask(gemini, geminiTools, toolClientMap, "What is the weather like in London?");
await Ask(
    gemini,
    geminiTools,
    toolClientMap,
    "Create a new OSS bucket called 'devcon-test2' with a persistent policy in the US region, then list my US buckets to confirm it was created.");
python
await ask("What is the weather like in London?")
await ask(
    "Create a new OSS bucket called 'devcon-test2' with a persistent policy "
    "in the US region, then list my US buckets to confirm it was created."
)

Gemini reads the descriptions of all tools your server exposes and picks the right ones for each request. You don't specify tool names. You don't write orchestration code. The model decides.

View complete agent.js in Source Code → · View complete Agent.cs in Source Code → · View complete agent.py in Source Code →

Run the Agent

bash
# Terminal 1
node aps-server.js

# Terminal 2
node server.js

# Terminal 3 - the agent
node agent.js
bash
# Terminal 1 - APS server
dotnet run -p:StartupObject=DevconWorkshop.ApsServer

# Terminal 2 - workshop server
dotnet run -p:StartupObject=DevconWorkshop.Server

# Terminal 3 - the agent (default StartupObject in csproj)
dotnet run
bash
# Terminal 1
python aps-server.py

# Terminal 2
python server.py

# Terminal 3 - the agent
python agent.py

.NET — <StartupObject> for the agent. Now that the agent is your usual entry-point, set <StartupObject>DevconWorkshop.Agent</StartupObject> in DevconWorkshop.csproj so plain dotnet run starts it. Use -p:StartupObject=... to switch to any of the other Main methods.

Challenges

A - Add a system prompt to shape the agent's personality. Before the message loop, prepend a system message:

javascript
const messages = [
  {
    role: "user",
    parts: [
      {
        text: "You are a concise assistant for AEC professionals. Always include units and be factual.",
      },
    ],
  },
  { role: "model", parts: [{ text: "Understood." }] },
  { role: "user", parts: [{ text: userPrompt }] },
];
csharp
var messages = new List<Content>
{
    new Content { Role = "user", Parts = new List<Part> { new Part { Text = "You are a concise assistant for AEC professionals. Always include units and be factual." } } },
    new Content { Role = "model", Parts = new List<Part> { new Part { Text = "Understood." } } },
    new Content { Role = "user", Parts = new List<Part> { new Part { Text = userPrompt } } },
};
python
messages: list[types.Content] = [
    types.Content(
        role="user",
        parts=[types.Part.from_text(
            text="You are a concise assistant for AEC professionals. Always include units and be factual."
        )],
    ),
    types.Content(role="model", parts=[types.Part.from_text(text="Understood.")]),
    types.Content(role="user", parts=[types.Part.from_text(text=user_prompt)]),
]

B - Build a simple REPL so you can type questions interactively:

javascript
import * as readline from "node:readline";

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

await new Promise((resolve) => {
  rl.on("close", resolve);
  const loop = () =>
    rl.question("\nYou: ", async (input) => {
      if (input === "exit") return rl.close();
      await ask(input);
      loop();
    });
  loop();
});
csharp
while (true)
{
    Console.Write("\nYou: ");
    var input = Console.ReadLine();
    if (input is null || input == "exit") break;
    await Ask(gemini, geminiTools, toolClientMap, input);
}
python
while True:
    user_input = await asyncio.to_thread(input, "\nYou: ")
    if user_input.strip() == "exit":
        break
    await ask(user_input)

Replace the hardcoded ask() / Ask() calls with this loop and you have a working AI assistant backed by your entire MCP system.

Python — why asyncio.to_thread? The built-in input() blocks the thread. Wrapping it in asyncio.to_thread(...) keeps the event loop free so the in-flight async MCP sessions stay responsive while waiting for you to type.

View complete solution agent.js in Source Code → · View complete solution Agent.cs in Source Code → · View complete solution agent.py in Source Code →

The Full Picture

              You (natural language)

          agent.js / Agent.cs / agent.py
                       ↓  listTools() on startup (both servers)
                       ↓  generateContent() + tools on each message
                Gemini 2.5 Flash
                       ↓  functionCall: { name, args }
          agent.js / Agent.cs / agent.py
                       ↓  toolClientMap[name].callTool()
              ┌────────┴────────────┐
              │                     │
        Workshop server         APS server
            :3000                  :3001
              │                     │
         Open-Meteo              APS API
          (HTTPS)

Every layer communicates over HTTP. Every tool is discovered dynamically. The agent routes each tool call to the right server using toolClientMap. That is MCP.

DevCon MCP Workshop