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:
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.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:
devcon-workshop/
├── .vscode/
│ └── mcp.json
├── node_modules/
├── .env
├── aps-server.js
├── package.json
├── package-lock.json
└── server.jsdevcon-workshop/
├── .vscode/
│ └── mcp.json
├── Properties/
│ └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs
├── DevconWorkshop.csproj
└── Server.csBuild 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.
devcon-workshop/
├── .vscode/
│ └── mcp.json
├── node_modules/
├── .env
├── aps-server.js
├── client.js ← new
├── package.json
├── package-lock.json
└── server.jsdevcon-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.csis the third entry-point in your project. You'll override<StartupObject>at runtime withdotnet run -p:StartupObject=DevconWorkshop.Clientto pick it. No csproj change needed.
The Imports / Usings - What Each One Does
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;Node.js — what each import does
| Import | What it does |
|---|---|
Client | The MCP client class. Manages the connection to the server and exposes methods like listTools() and callTool(). |
StreamableHTTPClientTransport | Connects 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
| Using | What it does |
|---|---|
ModelContextProtocol.Client | McpClient, HttpClientTransport, and HttpClientTransportOptions — the client-side surface of the MCP SDK. |
ModelContextProtocol.Protocol | Protocol-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:
dotnet add package ModelContextProtocol --version 1.3.0Section 1 - Connect to the servers
// 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.");// 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
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}`);
});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.
// 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();// 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.js —
result.content[0].textreads the first text block from the response. - .NET —
Content.OfType<TextContentBlock>().FirstOrDefault()?.Textis the equivalent: filter to text blocks, take the first, read itsText. Theawait usingdeclarations at the top ofMainhandle closing the clients automatically.
Run It
Start the servers, then run the client:
# Terminal 1
node aps-server.js
# Terminal 2
node server.js
# Terminal 3
node client.js# 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.ClientView 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.
- Go to aistudio.google.com
- Sign in with your Google account
- Click Get API key → Create API key
- Copy the key
Store it next to your APS credentials — .env for Node.js, User Secrets for .NET:
GEMINI_API_KEY="your-key-here"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
npm install @google/genaidotnet 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.
devcon-workshop/
├── .vscode/
│ └── mcp.json
├── node_modules/
├── .env
├── agent.js ← new
├── aps-server.js
├── client.js
├── package.json
├── package-lock.json
└── server.jsdevcon-workshop/
├── .vscode/
│ └── mcp.json
├── Properties/
│ └── launchSettings.json
├── Agent.cs ← new
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs
├── Client.cs
├── DevconWorkshop.csproj
└── Server.csThe Imports / Usings - What Each One Does
import { GoogleGenAI } from "@google/genai";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import "dotenv/config";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
| Import | What it does |
|---|---|
GoogleGenAI | The Gemini client. Sends prompts to the model and receives responses, including functionCall objects when the model wants to use a tool. |
Client | The MCP client, same as in client.js. Connects to your MCP servers and calls tools on behalf of the LLM. |
StreamableHTTPClientTransport | Connects the MCP client to your servers over HTTP. |
.NET — what each using does
| Using | What it does |
|---|---|
Google.GenAI + Google.GenAI.Types | Gemini's .NET SDK — Client, Content, Part, FunctionDeclaration, Schema, Tool. |
ModelContextProtocol.Client + .Protocol | The 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.Json | Converts 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
// 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}`));// 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:
const geminiTools = [
{
functionDeclarations: allTools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
})),
},
];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.js —
tool.inputSchemais already a plain JS object in the JSON Schema shape Gemini expects, so it passes through verbatim. - .NET —
t.JsonSchemais aJsonElement, while Gemini expects a strongly-typedSchemaobject.ConvertSchemawalks the JsonElement and produces an equivalentSchema, handlingtype,description,enum,properties,required, anditems.
Section 3 - The Tool-Calling Loop
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 });
}
}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:
- We send the user prompt + available tools to Gemini
- Gemini either answers or says "I need to call tool X with arguments Y"
- We execute the tool call via MCP and add the result back to the conversation
- 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-test2with a unique name using only lowercase letters, numbers, and dashes.
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();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
# Terminal 1
node aps-server.js
# Terminal 2
node server.js
# Terminal 3 - the agent
node agent.js# 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>inDevconWorkshop.csprojso plaindotnet runstarts it. Use-p:StartupObject=...to switch to any of the otherMainmethods.
Challenges
A - Add a system prompt to shape the agent's personality. Before the message loop, prepend a system message:
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 }] },
];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:
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();
});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.