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: 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: 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
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs
├── DevconWorkshop.csproj
└── Server.cs

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.

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
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs
├── Client.cs           ← new
├── DevconWorkshop.csproj
└── Server.cs

.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;

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).

The .NET client also needs a new package — the client side of MCP lives in ModelContextProtocol, separate from the ModelContextProtocol.AspNetCore server package:

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.");

Client.connect() (Node.js) and McpClient.CreateAsync() (.NET) 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.

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}");
}

listTools() / ListToolsAsync() 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}");
  • 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.

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

View complete client.js in Source Code → · View complete Client.cs 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, User Secrets for .NET:

bash
GEMINI_API_KEY="your-key-here"
bash
dotnet user-secrets set "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

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

Build the Agent

Create a new file called agent.js (Node.js) or Agent.cs (.NET). 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
├── Properties/
│   └── launchSettings.json
├── Agent.cs            ← new
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs
├── Client.cs
├── DevconWorkshop.csproj
└── Server.cs

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;

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.

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}");
}

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;
}

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.

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 });
    }
}

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.");

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 →

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

.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 } } },
};

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);
}

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

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

The Full Picture

              You (natural language)

              agent.js / Agent.cs
                       ↓  listTools() on startup (both servers)
                       ↓  generateContent() + tools on each message
                Gemini 2.5 Flash
                       ↓  functionCall: { name, args }
              agent.js / Agent.cs
                       ↓  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