Note
The Java client examples connect through the legacy HTTP+SSE transport and
target MCP 2025-11-25 SDK APIs. Use a 2026-07-28-compatible SDK and
Streamable HTTP for new remote clients.
So far, you've seen how to create a server and a client. The client has been able to call the server explicitly to list its tools, resources, and prompts. However, this is not a very practical approach. Your users live in the agentic era and expect to use prompts and communicate with an LLM instead. They do not care whether you use MCP to store your capabilities; they simply expect to interact using natural language. So how do we solve this? The solution is to add an LLM to the client.
In this lesson we focus on adding an LLM to do your client and show how this provides a much better experience for your user.
By the end of this lesson, you will be able to:
- Create a client with an LLM.
- Seamlessly interact with an MCP server using an LLM.
- Provide a better end user experience on the client side.
Let's try to understand the approach we need to take. Adding an LLM sounds simple, but will we actually do this?
Here's how the client will interact with the server:
-
Establish connection with server.
-
List capabilities, prompts, resources and tools, and save down their schema.
-
Add an LLM and pass the saved capabilities and their schema in a format the LLM understands.
-
Handle a user prompt by passing it to the LLM together with the tools listed by the client.
Great, now we understand how we can do this at high level, let's try this out in below exercise.
In this exercise, we will learn to add an LLM to our client.
GitHub Models was retired on July 30, 2026. Create a Microsoft Foundry resource,
deploy an active model such as gpt-5.1, and set these environment variables:
export AZURE_OPENAI_ENDPOINT="https://<resource-name>.openai.azure.com"
export AZURE_OPENAI_API_KEY="<api-key>"
export AZURE_OPENAI_DEPLOYMENT="gpt-5.1"Use the deployment name in API calls. It may differ from the underlying model name. Review the Microsoft Foundry model retirement schedule before choosing a model.
Let's create our client first:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import OpenAI from "openai";
import { z } from "zod"; // Import zod for schema validation
class MCPClient {
private openai: OpenAI;
private client: Client;
constructor(){
const endpoint = process.env.AZURE_OPENAI_ENDPOINT;
const apiKey = process.env.AZURE_OPENAI_API_KEY;
if (!endpoint || !apiKey) {
throw new Error("AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY must be set");
}
this.openai = new OpenAI({
baseURL: `${endpoint.replace(/\/$/, "")}/openai/v1/`,
apiKey,
});
this.client = new Client(
{
name: "example-client",
version: "1.0.0"
},
{
capabilities: {}
}
);
}
}In the preceding code we've:
- Imported the needed libraries
- Create a class with two members,
clientandopenaithat will help us manage a client and interact with an LLM respectively. - Configured the OpenAI client to use the Microsoft Foundry v1 endpoint and API key.
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
# Create server parameters for stdio connection
server_params = StdioServerParameters(
command="mcp", # Executable
args=["run", "server.py"], # Optional command line arguments
env=None, # Optional environment variables
)
async def run():
async with stdio_client(server_params) as (read, write):
async with ClientSession(
read, write
) as session:
# Initialize the connection
await session.initialize()
if __name__ == "__main__":
import asyncio
asyncio.run(run())In the preceding code we've:
- Imported the needed libraries for MCP
- Created a client
using ModelContextProtocol.Client;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.Text.Json;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT");
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-5.1";
var client = new ChatClient(
deployment,
new ApiKeyCredential(apiKey),
new OpenAIClientOptions
{
Endpoint = new Uri($"{endpoint.TrimEnd('/')}/openai/v1/")
});
var clientTransport = new StdioClientTransport(new()
{
Name = "Demo Server",
Command = "/workspaces/mcp-for-beginners/03-GettingStarted/02-client/solution/server/bin/Debug/net9.0/server",
Arguments = [],
});
await using var mcpClient = await McpClient.CreateAsync(clientTransport);First, you'll need to add the LangChain4j dependencies to your pom.xml file. Add these dependencies to enable MCP integration and the OpenAI-compatible MiniMax API:
<properties>
<langchain4j.version>1.0.0-beta3</langchain4j.version>
</properties>
<dependencies>
<!-- LangChain4j MCP Integration -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mcp</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- OpenAI Official API Client -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-official</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- Spring Boot Starter (optional, for production apps) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>Set your MiniMax API key and, optionally, the endpoint and model.
MINIMAX_MODEL_ID supports MiniMax-M3 and MiniMax-M2.7. If
OPENAI_BASE_URL is not set, MINIMAX_REGION supports global_en and cn_zh.
export OPENAI_API_KEY=your_minimax_api_key_here
export OPENAI_BASE_URL=https://api.minimax.io/v1
export MINIMAX_MODEL_ID=MiniMax-M3To select the endpoint by region instead, omit OPENAI_BASE_URL:
unset OPENAI_BASE_URL
export MINIMAX_REGION=cn_zhThen create your Java client class:
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.transport.McpTransport;
import dev.langchain4j.mcp.client.transport.http.HttpMcpTransport;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.tool.ToolProvider;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public class LangChain4jClient {
private static final String DEFAULT_BASE_URL = "https://api.minimax.io/v1";
private static final String DEFAULT_MODEL_ID = "MiniMax-M3";
private static final Map<String, String> REGIONAL_BASE_URLS = Map.of(
"global_en", "https://api.minimax.io/v1",
"cn_zh", "https://api.minimaxi.com/v1");
private static final Set<String> SUPPORTED_MODEL_IDS = Set.of("MiniMax-M3", "MiniMax-M2.7");
public static void main(String[] args) throws Exception {
ChatLanguageModel model = OpenAiOfficialChatModel.builder()
.baseUrl(resolveBaseUrl())
.apiKey(requireEnv("OPENAI_API_KEY"))
.timeout(Duration.ofSeconds(60))
.modelName(resolveModelName())
.build();
// Create MCP transport for connecting to server
McpTransport transport = new HttpMcpTransport.Builder()
.sseUrl("http://localhost:8080/sse")
.timeout(Duration.ofSeconds(60))
.logRequests(true)
.logResponses(true)
.build();
// Create MCP client
McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(transport)
.build();
}
private static String resolveBaseUrl() {
String baseUrl = System.getenv("OPENAI_BASE_URL");
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl;
}
String region = System.getenv("MINIMAX_REGION");
if (region == null || region.isBlank()) {
return DEFAULT_BASE_URL;
}
String regionalBaseUrl = REGIONAL_BASE_URLS.get(region);
if (regionalBaseUrl == null) {
throw new IllegalArgumentException("Unsupported MINIMAX_REGION value: " + region
+ ". Supported values: " + new TreeSet<>(REGIONAL_BASE_URLS.keySet()));
}
return regionalBaseUrl;
}
private static String resolveModelName() {
String modelId = System.getenv("MINIMAX_MODEL_ID");
if (modelId == null || modelId.isBlank()) {
return DEFAULT_MODEL_ID;
}
if (!SUPPORTED_MODEL_IDS.contains(modelId)) {
throw new IllegalArgumentException("Unsupported MINIMAX_MODEL_ID value: " + modelId
+ ". Supported values: " + new TreeSet<>(SUPPORTED_MODEL_IDS));
}
return modelId;
}
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " environment variable is not set");
}
return value;
}
}In the preceding code we've:
- Added LangChain4j dependencies: Required for MCP integration and the OpenAI-compatible MiniMax API
- Imported the LangChain4j libraries: For MCP integration and OpenAI chat model functionality
- Created a
ChatLanguageModel: Configured to use MiniMax with your MiniMax API key, endpoint, and supported model ID - Set up HTTP transport: Using Server-Sent Events (SSE) to connect to the MCP server
- Created an MCP client: That will handle communication with the server
- Used LangChain4j's built-in MCP support: Which simplifies integration between LLMs and MCP servers
This example assumes you have a Rust based MCP server running. If you don't have one, refer back to the 01-first-server lesson to create the server.
Once you have your Rust MCP server, open a terminal and navigate to the same directory as the server. Then run the following command to create a new LLM client project:
mkdir calculator-llmclient
cd calculator-llmclient
cargo initAdd the following dependencies to your Cargo.toml file:
[dependencies]
async-openai = { version = "0.29.0", features = ["byot"] }
rmcp = { version = "0.5.0", features = ["client", "transport-child-process"] }
serde_json = "1.0.141"
tokio = { version = "1.46.1", features = ["rt-multi-thread"] }Note
There isn't an official Rust library for OpenAI, however, the async-openai crate is a community maintained library that is commonly used.
Open the src/main.rs file and replace its content with the following code:
use async_openai::{Client, config::OpenAIConfig};
use rmcp::{
RmcpError,
model::{CallToolRequestParam, ListToolsResult},
service::{RoleClient, RunningService, ServiceExt},
transport::{ConfigureCommandExt, TokioChildProcess},
};
use serde_json::{Value, json};
use std::error::Error;
use tokio::process::Command;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// Initial message
let mut messages = vec![json!({"role": "user", "content": "What is the sum of 3 and 2?"})];
// Setup Microsoft Foundry client
let endpoint = std::env::var("AZURE_OPENAI_ENDPOINT")?;
let api_key = std::env::var("AZURE_OPENAI_API_KEY")?;
let openai_client = Client::with_config(
OpenAIConfig::new()
.with_api_base(format!("{}/openai/v1", endpoint.trim_end_matches('/')))
.with_api_key(api_key),
);
// Setup MCP client
let server_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("calculator-server");
let mcp_client = ()
.serve(
TokioChildProcess::new(Command::new("cargo").configure(|cmd| {
cmd.arg("run").current_dir(server_dir);
}))
.map_err(RmcpError::transport_creation::<TokioChildProcess>)?,
)
.await?;
// TODO: Get MCP tool listing
// TODO: LLM conversation with tool calls
Ok(())
}This code sets up a Rust application that connects to an MCP server and a Microsoft Foundry model deployment for LLM interactions.
Important
Set AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, and
AZURE_OPENAI_DEPLOYMENT before running the application.
Great, for our next step, let's list the capabilities on the server.
Now we will connect to the server and ask for its capabilities:
In the same class, add the following methods:
async connectToServer(transport: Transport) {
await this.client.connect(transport);
this.run();
console.error("MCPClient started on stdin/stdout");
}
async run() {
console.log("Asking server for available tools");
// listing tools
const toolsResult = await this.client.listTools();
}In the preceding code we've:
- Added code for connecting to the server,
connectToServer. - Created a
runmethod responsible for handling our app flow. So far it only lists the tools but we will add more to it shortly.
# List available resources
resources = await session.list_resources()
print("LISTING RESOURCES")
for resource in resources:
print("Resource: ", resource)
# List available tools
tools = await session.list_tools()
print("LISTING TOOLS")
for tool in tools.tools:
print("Tool: ", tool.name)
print("Tool", tool.inputSchema["properties"])Here's what we added:
- Listing resources and tools and printed them. For tools we also list
inputSchemawhich we use later.
async Task<List<ChatTool>> GetMcpTools()
{
Console.WriteLine("Listing tools");
var tools = await mcpClient.ListToolsAsync();
List<ChatTool> toolDefinitions = [];
foreach (var tool in tools)
{
Console.WriteLine($"Connected to server with tools: {tool.Name}");
Console.WriteLine($"Tool description: {tool.Description}");
Console.WriteLine($"Tool parameters: {tool.JsonSchema}");
// TODO: convert tool definition from MCP tool to LLm tool
}
return toolDefinitions;
}In the preceding code we've:
- Listed the tools available on the MCP Server
- For each tool, listed name, description and its schema. The latter is something we will use to call the tools shortly.
// Create a tool provider that automatically discovers MCP tools
ToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(List.of(mcpClient))
.build();
// The MCP tool provider automatically handles:
// - Listing available tools from the MCP server
// - Converting MCP tool schemas to LangChain4j format
// - Managing tool execution and responsesIn the preceding code we've:
- Created a
McpToolProviderthat automatically discovers and registers all tools from the MCP server - The tool provider handles the conversion between MCP tool schemas and LangChain4j's tool format internally
- This approach abstracts away the manual tool listing and conversion process
Retrieving tools from the MCP server is done using the list_tools method. In your main function, after setting up the MCP client, add the following code:
// Get MCP tool listing
let tools = mcp_client.list_tools(Default::default()).await?;Next step after listing server capabilities is to convert them into a format that the LLM understands. Once we do that, we can provide these capabilities as tools to our LLM.
-
Add the following code to convert response from MCP Server to a tool format the LLM can use:
openAiToolAdapter(tool: { name: string; description?: string; input_schema: any; }) { // Create a zod schema based on the input_schema const schema = z.object(tool.input_schema); return { type: "function" as const, // Explicitly set type to "function" function: { name: tool.name, description: tool.description, parameters: { type: "object", properties: tool.input_schema.properties, required: tool.input_schema.required, }, }, }; }
The above code takes a response from the MCP Server and converts that to a tool definition format the LLM can understand.
-
Let's update the
runmethod next to list server capabilities:async run() { console.log("Asking server for available tools"); const toolsResult = await this.client.listTools(); const tools = toolsResult.tools.map((tool) => { return this.openAiToolAdapter({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, }); }); }
In the preceding code, we've update the
runmethod to map through the result and for each entry callopenAiToolAdapter.
-
First, let's create the following converter function
def convert_to_llm_tool(tool): tool_schema = { "type": "function", "function": { "name": tool.name, "description": tool.description, "type": "function", "parameters": { "type": "object", "properties": tool.inputSchema["properties"] } } } return tool_schema
In the function above
convert_to_llm_toolswe take an MCP tool response and convert it to a format that the LLM can understand. -
Next, let's update our client code to leverage this function like so:
functions = [] for tool in tools.tools: print("Tool: ", tool.name) print("Tool", tool.inputSchema["properties"]) functions.append(convert_to_llm_tool(tool))
Here, we're adding a call to
convert_to_llm_toolto convert the MCP tool response to something we can feed the LLM later.
- Let's add code to convert the MCP tool response to something the LLM can understand
ChatTool ConvertFrom(string name, string description, JsonElement jsonElement)
{
return ChatTool.CreateFunctionTool(
functionName: name,
functionDescription: description,
functionParameters: BinaryData.FromString(jsonElement.GetRawText()));
}In the preceding code we've:
- Created a function
ConvertFromthat takes, name, description and input schema. - Created an OpenAI
ChatToolfrom the MCP tool's JSON schema.
-
Let's see how we can update some existing code to take advantage of this function above:
async Task<List<ChatTool>> GetMcpTools() { Console.WriteLine("Listing tools"); var tools = await mcpClient.ListToolsAsync(); List<ChatTool> toolDefinitions = []; foreach (var tool in tools) { Console.WriteLine($"Connected to server with tools: {tool.Name}"); Console.WriteLine($"Tool description: {tool.Description}"); Console.WriteLine($"Tool parameters: {tool.JsonSchema}"); var def = ConvertFrom(tool.Name, tool.Description, tool.JsonSchema); Console.WriteLine($"Tool definition: {def}"); toolDefinitions.Add(def); } return toolDefinitions; }
In the preceding code, we've:
-
Update the function to convert the MCP tool response to an LLM tool. Let's highlight the code we added:
JsonElement propertiesElement; tool.JsonSchema.TryGetProperty("properties", out propertiesElement); var def = ConvertFrom(tool.Name, tool.Description, propertiesElement); Console.WriteLine($"Tool definition: {def}"); toolDefinitions.Add(def);
The input schema is part of the tool response but on the "properties" attribute, so we need to extract. Furthermore, we now call ConvertFrom with the tool details. Now we've done the heavy lifting, let's see how it call comes together as we handle a user prompt next.
// Create a Bot interface for natural language interaction
public interface Bot {
String chat(String prompt);
}
// Configure the AI service with LLM and MCP tools
Bot bot = AiServices.builder(Bot.class)
.chatLanguageModel(model)
.toolProvider(toolProvider)
.build();In the preceding code we've:
- Defined a simple
Botinterface for natural language interactions - Used LangChain4j's
AiServicesto automatically bind the LLM with the MCP tool provider - The framework automatically handles tool schema conversion and function calling behind the scenes
- This approach eliminates manual tool conversion - LangChain4j handles all the complexity of converting MCP tools to LLM-compatible format
To convert the MCP tool response to a format that the LLM can understand, we will add a helper function that formats the tools listing. Add the following code to your main.rs file below the main function. This will be called when making requests to the LLM:
async fn format_tools(tools: &ListToolsResult) -> Result<Vec<Value>, Box<dyn Error>> {
let tools_json = serde_json::to_value(tools)?;
let Some(tools_array) = tools_json.get("tools").and_then(|t| t.as_array()) else {
return Ok(vec![]);
};
let formatted_tools = tools_array
.iter()
.filter_map(|tool| {
let name = tool.get("name")?.as_str()?;
let description = tool.get("description")?.as_str()?;
let schema = tool.get("inputSchema")?;
Some(json!({
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": schema.get("properties").unwrap_or(&json!({})),
"required": schema.get("required").unwrap_or(&json!([]))
}
}
}))
})
.collect();
Ok(formatted_tools)
}Great, we're not set up to handle any user requests, so let's tackle that next.
In this part of the code, we will handle user requests.
-
Add a method that will be used to call our LLM:
async callTools( tool_calls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[], toolResults: any[] ) { for (const tool_call of tool_calls) { const toolName = tool_call.function.name; const args = tool_call.function.arguments; console.log(`Calling tool ${toolName} with args ${JSON.stringify(args)}`); // 2. Call the server's tool const toolResult = await this.client.callTool({ name: toolName, arguments: JSON.parse(args), }); console.log("Tool result: ", toolResult); // 3. Do something with the result // TODO } }
In the preceding code we:
-
Added a method
callTools. -
The method takes an LLM response and checks to see what tools have been called, if any:
for (const tool_call of tool_calls) { const toolName = tool_call.function.name; const args = tool_call.function.arguments; console.log(`Calling tool ${toolName} with args ${JSON.stringify(args)}`); // call tool }
-
Calls a tool, if LLM indicates it should be called:
// 2. Call the server's tool const toolResult = await this.client.callTool({ name: toolName, arguments: JSON.parse(args), }); console.log("Tool result: ", toolResult); // 3. Do something with the result // TODO
-
-
Update the
runmethod to include calls to the LLM and callingcallTools:// 1. Create messages that's input for the LLM const prompt = "What is the sum of 2 and 3?" const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ { role: "user", content: prompt, }, ]; console.log("Querying LLM: ", messages[0].content); // 2. Calling the LLM let response = this.openai.chat.completions.create({ model: process.env.AZURE_OPENAI_DEPLOYMENT ?? "gpt-5.1", max_completion_tokens: 1000, messages, tools: tools, }); let results: any[] = []; // 3. Go through the LLM response,for each choice, check if it has tool calls (await response).choices.map(async (choice: { message: any; }) => { const message = choice.message; if (message.tool_calls) { console.log("Making tool call") await this.callTools(message.tool_calls, results); } });
Great, let's list the code in full:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import OpenAI from "openai";
import { z } from "zod"; // Import zod for schema validation
class MyClient {
private openai: OpenAI;
private client: Client;
constructor(){
const endpoint = process.env.AZURE_OPENAI_ENDPOINT;
const apiKey = process.env.AZURE_OPENAI_API_KEY;
if (!endpoint || !apiKey) {
throw new Error("AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY must be set");
}
this.openai = new OpenAI({
baseURL: `${endpoint.replace(/\/$/, "")}/openai/v1/`,
apiKey,
});
this.client = new Client(
{
name: "example-client",
version: "1.0.0"
},
{
capabilities: {}
}
);
}
async connectToServer(transport: Transport) {
await this.client.connect(transport);
this.run();
console.error("MCPClient started on stdin/stdout");
}
openAiToolAdapter(tool: {
name: string;
description?: string;
input_schema: any;
}) {
// Create a zod schema based on the input_schema
const schema = z.object(tool.input_schema);
return {
type: "function" as const, // Explicitly set type to "function"
function: {
name: tool.name,
description: tool.description,
parameters: {
type: "object",
properties: tool.input_schema.properties,
required: tool.input_schema.required,
},
},
};
}
async callTools(
tool_calls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[],
toolResults: any[]
) {
for (const tool_call of tool_calls) {
const toolName = tool_call.function.name;
const args = tool_call.function.arguments;
console.log(`Calling tool ${toolName} with args ${JSON.stringify(args)}`);
// 2. Call the server's tool
const toolResult = await this.client.callTool({
name: toolName,
arguments: JSON.parse(args),
});
console.log("Tool result: ", toolResult);
// 3. Do something with the result
// TODO
}
}
async run() {
console.log("Asking server for available tools");
const toolsResult = await this.client.listTools();
const tools = toolsResult.tools.map((tool) => {
return this.openAiToolAdapter({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
});
});
const prompt = "What is the sum of 2 and 3?";
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "user",
content: prompt,
},
];
console.log("Querying LLM: ", messages[0].content);
let response = this.openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT ?? "gpt-5.1",
max_completion_tokens: 1000,
messages,
tools: tools,
});
let results: any[] = [];
// 3. Go through the LLM response, for each choice, check if it has tool calls
(await response).choices.map(async (choice: { message: any; }) => {
const message = choice.message;
if (message.tool_calls) {
console.log("Making tool call")
await this.callTools(message.tool_calls, results);
}
});
}
}
let client = new MyClient();
const transport = new StdioClientTransport({
command: "node",
args: ["./build/index.js"]
});
client.connectToServer(transport);-
Let's add some imports needed to call an LLM
# llm import os from openai import OpenAI import json
-
Next, let's add the function that will call the LLM. This example uses the active
gpt-5.1model. Check the Microsoft Foundry model retirement schedule when selecting a model for your application.# llm def call_llm(prompt, functions): client = OpenAI( base_url=( f"{os.environ['AZURE_OPENAI_ENDPOINT'].rstrip('/')}/openai/v1/" ), api_key=os.environ["AZURE_OPENAI_API_KEY"], ) print("CALLING LLM") response = client.chat.completions.create( messages=[ { "role": "system", "content": "You are a helpful assistant.", }, { "role": "user", "content": prompt, }, ], model=os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-5.1"), tools=functions, max_completion_tokens=1000, ) response_message = response.choices[0].message functions_to_call = [] if response_message.tool_calls: for tool_call in response_message.tool_calls: print("TOOL: ", tool_call) name = tool_call.function.name args = json.loads(tool_call.function.arguments) functions_to_call.append({ "name": name, "args": args }) return functions_to_call
In the preceding code we've:
- Passed our functions, that we found on the MCP server and converted, to the LLM.
- Then we called the LLM with said functions.
- Then, we're inspecting the result to see what functions we should call, if any.
- Finally, we pass an array of functions to call.
-
Final step, let's update our main code:
prompt = "Add 2 to 20" # ask LLM what tools to all, if any functions_to_call = call_llm(prompt, functions) # call suggested functions for f in functions_to_call: result = await session.call_tool(f["name"], arguments=f["args"]) print("TOOLS result: ", result.content)
There, that was the final step, in the code above we're:
- Calling an MCP tool via
call_toolusing a function that the LLM thought we should call based on our prompt. - Printing the result of the tool call to the MCP Server.
- Calling an MCP tool via
-
Let's show some code for doing an LLM prompt request:
var tools = await GetMcpTools(); for (int i = 0; i < tools.Count; i++) { var tool = tools[i]; Console.WriteLine($"MCP Tools def: {i}: {tool}"); } // 0. Define the chat history and the user message var userMessage = "add 2 and 4"; chatHistory.Add(new UserChatMessage(userMessage)); // 2. Define options, including the tools var options = new ChatCompletionOptions { Tools = { tools[0] } }; // 3. Call the model ChatCompletion response = await client.CompleteChatAsync(chatHistory, options); var content = response.Content.FirstOrDefault()?.Text;
In the preceding code we've:
- Fetched tools from the MCP server,
var tools = await GetMcpTools(). - Defined a user prompt
userMessage. - Constructor an options object specifying model and tools.
- Made a request towards the LLM.
- Fetched tools from the MCP server,
-
One last step, let's see if the LLM thinks we should call a function:
// 4. Check if the response contains a function call for (int i = 0; i < response.ToolCalls.Count; i++) { var call = response.ToolCalls[i]; Console.WriteLine($"Tool call {i}: {call.FunctionName} with arguments {call.FunctionArguments}"); //Tool call 0: add with arguments {"a":2,"b":4} var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(call.FunctionArguments); var result = await mcpClient.CallToolAsync( call.FunctionName, dict!, cancellationToken: CancellationToken.None ); var textBlock = result.Content.OfType<TextContentBlock>().FirstOrDefault(); if (textBlock != null) { Console.WriteLine(textBlock.Text); } }
In the preceding code we've:
- Looped through a list of function calls.
- For each tool call, parse out name and arguments and call the tool on the MCP server using the MCP client. Finally we print the results.
Here's the code in full:
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.Text.Json;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT");
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-5.1";
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
{
Console.WriteLine("Please set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY.");
return;
}
var client = new ChatClient(
model: deployment,
credential: new ApiKeyCredential(apiKey),
options: new OpenAIClientOptions
{
Endpoint = new Uri($"{endpoint.TrimEnd('/')}/openai/v1/")
});
var chatHistory = new List<ChatMessage>
{
new SystemChatMessage("You are a helpful assistant that knows about AI")
};
var clientTransport = new StdioClientTransport(new()
{
Name = "Demo Server",
Command = "/workspaces/mcp-for-beginners/03-GettingStarted/02-client/solution/server/bin/Debug/net9.0/server",
Arguments = [],
});
Console.WriteLine("Setting up stdio transport");
await using var mcpClient = await McpClient.CreateAsync(clientTransport);
ChatTool ConvertFrom(string name, string description, JsonElement jsonElement)
{
return ChatTool.CreateFunctionTool(
functionName: name,
functionDescription: description,
functionParameters: BinaryData.FromString(jsonElement.GetRawText()));
}
async Task<List<ChatTool>> GetMcpTools()
{
Console.WriteLine("Listing tools");
var tools = await mcpClient.ListToolsAsync();
List<ChatTool> toolDefinitions = [];
foreach (var tool in tools)
{
Console.WriteLine($"Connected to server with tools: {tool.Name}");
Console.WriteLine($"Tool description: {tool.Description}");
Console.WriteLine($"Tool parameters: {tool.JsonSchema}");
var def = ConvertFrom(tool.Name, tool.Description, tool.JsonSchema);
Console.WriteLine($"Tool definition: {def}");
toolDefinitions.Add(def);
}
return toolDefinitions;
}
// 1. List tools on mcp server
var tools = await GetMcpTools();
for (int i = 0; i < tools.Count; i++)
{
var tool = tools[i];
Console.WriteLine($"MCP Tools def: {i}: {tool}");
}
// 2. Define the chat history and the user message
var userMessage = "add 2 and 4";
chatHistory.Add(new UserChatMessage(userMessage));
// 3. Define options, including the tools
var options = new ChatCompletionOptions
{
Tools = { tools[0] }
};
// 4. Call the model
ChatCompletion response = await client.CompleteChatAsync(chatHistory, options);
var content = response.Content.FirstOrDefault()?.Text;
// 5. Check if the response contains a function call
for (int i = 0; i < response.ToolCalls.Count; i++)
{
var call = response.ToolCalls[i];
Console.WriteLine($"Tool call {i}: {call.FunctionName} with arguments {call.FunctionArguments}");
//Tool call 0: add with arguments {"a":2,"b":4}
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(call.FunctionArguments);
var result = await mcpClient.CallToolAsync(
call.FunctionName,
dict!,
cancellationToken: CancellationToken.None
);
var textBlock = result.Content.OfType<TextContentBlock>().FirstOrDefault();
if (textBlock != null)
{
Console.WriteLine(textBlock.Text);
}
}
// 6. Print the generic response
Console.WriteLine($"Assistant response: {content}");try {
// Execute natural language requests that automatically use MCP tools
String response = bot.chat("Calculate the sum of 24.5 and 17.3 using the calculator service");
System.out.println(response);
response = bot.chat("What's the square root of 144?");
System.out.println(response);
response = bot.chat("Show me the help for the calculator service");
System.out.println(response);
} finally {
mcpClient.close();
}In the preceding code we've:
- Used simple natural language prompts to interact with the MCP server tools
- The LangChain4j framework automatically handles:
- Converting user prompts to tool calls when needed
- Calling the appropriate MCP tools based on the LLM's decision
- Managing the conversation flow between the LLM and MCP server
- The
bot.chat()method returns natural language responses that may include results from MCP tool executions - This approach provides a seamless user experience where users don't need to know about the underlying MCP implementation
Complete code example:
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.transport.McpTransport;
import dev.langchain4j.mcp.client.transport.http.HttpMcpTransport;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.tool.ToolProvider;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public class LangChain4jClient {
private static final String DEFAULT_BASE_URL = "https://api.minimax.io/v1";
private static final String DEFAULT_MODEL_ID = "MiniMax-M3";
private static final Map<String, String> REGIONAL_BASE_URLS = Map.of(
"global_en", "https://api.minimax.io/v1",
"cn_zh", "https://api.minimaxi.com/v1");
private static final Set<String> SUPPORTED_MODEL_IDS = Set.of("MiniMax-M3", "MiniMax-M2.7");
public static void main(String[] args) throws Exception {
ChatLanguageModel model = OpenAiOfficialChatModel.builder()
.baseUrl(resolveBaseUrl())
.apiKey(requireEnv("OPENAI_API_KEY"))
.timeout(Duration.ofSeconds(60))
.modelName(resolveModelName())
.build();
McpTransport transport = new HttpMcpTransport.Builder()
.sseUrl("http://localhost:8080/sse")
.timeout(Duration.ofSeconds(60))
.logRequests(true)
.logResponses(true)
.build();
McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(transport)
.build();
ToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(List.of(mcpClient))
.build();
Bot bot = AiServices.builder(Bot.class)
.chatLanguageModel(model)
.toolProvider(toolProvider)
.build();
try {
String response = bot.chat("Calculate the sum of 24.5 and 17.3 using the calculator service");
System.out.println(response);
response = bot.chat("What's the square root of 144?");
System.out.println(response);
response = bot.chat("Show me the help for the calculator service");
System.out.println(response);
} finally {
mcpClient.close();
}
}
private static String resolveBaseUrl() {
String baseUrl = System.getenv("OPENAI_BASE_URL");
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl;
}
String region = System.getenv("MINIMAX_REGION");
if (region == null || region.isBlank()) {
return DEFAULT_BASE_URL;
}
String regionalBaseUrl = REGIONAL_BASE_URLS.get(region);
if (regionalBaseUrl == null) {
throw new IllegalArgumentException("Unsupported MINIMAX_REGION value: " + region
+ ". Supported values: " + new TreeSet<>(REGIONAL_BASE_URLS.keySet()));
}
return regionalBaseUrl;
}
private static String resolveModelName() {
String modelId = System.getenv("MINIMAX_MODEL_ID");
if (modelId == null || modelId.isBlank()) {
return DEFAULT_MODEL_ID;
}
if (!SUPPORTED_MODEL_IDS.contains(modelId)) {
throw new IllegalArgumentException("Unsupported MINIMAX_MODEL_ID value: " + modelId
+ ". Supported values: " + new TreeSet<>(SUPPORTED_MODEL_IDS));
}
return modelId;
}
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " environment variable is not set");
}
return value;
}
}Here is where the majority of the work happens. We will call the LLM with the initial user prompt, then process the response to see if any tools need to be called. If so, we will call those tools and continue the conversation with the LLM until no more tool calls are needed and we have a final response.
We will be making multiple calls to the LLM, so let's define a function that will handle the LLM call. Add the following function to your main.rs file:
async fn call_llm(
client: &Client<OpenAIConfig>,
messages: &[Value],
tools: &ListToolsResult,
) -> Result<Value, Box<dyn Error>> {
let model = std::env::var("AZURE_OPENAI_DEPLOYMENT")
.unwrap_or_else(|_| "gpt-5.1".to_string());
let response = client
.completions()
.create_byot(json!({
"messages": messages,
"model": model,
"tools": format_tools(tools).await?,
}))
.await?;
Ok(response)
}This function takes the LLM client, a list of messages (including the user prompt), tools from the MCP server, and sends a request to the LLM, returning the response.
The response from the LLM will contain an array of choices. We will need to process the result to see if any tool_calls are present. This let's us know the LLM is requesting a specific tool should be called with arguments. Add the following code to the bottom of your main.rs file to define a function to handle the LLM response:
async fn process_llm_response(
llm_response: &Value,
mcp_client: &RunningService<RoleClient, ()>,
openai_client: &Client<OpenAIConfig>,
mcp_tools: &ListToolsResult,
messages: &mut Vec<Value>,
) -> Result<(), Box<dyn Error>> {
let Some(message) = llm_response
.get("choices")
.and_then(|c| c.as_array())
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
else {
return Ok(());
};
// Print content if available
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
println!("🤖 {}", content);
}
// Handle tool calls
if let Some(tool_calls) = message.get("tool_calls").and_then(|tc| tc.as_array()) {
messages.push(message.clone()); // Add assistant message
// Execute each tool call
for tool_call in tool_calls {
let (tool_id, name, args) = extract_tool_call_info(tool_call)?;
println!("⚡ Calling tool: {}", name);
let result = mcp_client
.call_tool(CallToolRequestParam {
name: name.into(),
arguments: serde_json::from_str::<Value>(&args)?.as_object().cloned(),
})
.await?;
// Add tool result to messages
messages.push(json!({
"role": "tool",
"tool_call_id": tool_id,
"content": serde_json::to_string_pretty(&result)?
}));
}
// Continue conversation with tool results
let response = call_llm(openai_client, messages, mcp_tools).await?;
Box::pin(process_llm_response(
&response,
mcp_client,
openai_client,
mcp_tools,
messages,
))
.await?;
}
Ok(())
}If tool_calls are present, it extracts the tool information, calls the MCP server with the tool request, and adds the results to the conversation messages. It then continues the conversation with the LLM and the messages are updated with the assistant's response and tool call results.
To extract tool call information that the LLM returns for MCP calls, we will add another helper function to extract everything needed to make the call. Add the following code to the bottom of your main.rs file:
fn extract_tool_call_info(tool_call: &Value) -> Result<(String, String, String), Box<dyn Error>> {
let tool_id = tool_call
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let function = tool_call.get("function").ok_or("Missing function")?;
let name = function
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let args = function
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}")
.to_string();
Ok((tool_id, name, args))
}With all the pieces in place, we can now handle the initial user prompt and call the LLM. Update your main function to include the following code:
// LLM conversation with tool calls
let response = call_llm(&openai_client, &messages, &tools).await?;
process_llm_response(
&response,
&mcp_client,
&openai_client,
&tools,
&mut messages,
)
.await?;This will query the LLM with the initial user prompt asking for the sum of two numbers, and it will process the response to dynamically handle tool calls.
Great, you did it!
Take the code from the exercise and build out the server with some more tools. Then create a client with an LLM, like in the exercise, and test it out with different prompts to make sure all your server tools gets called dynamically. This way of building a client means the end user will have a great user experience as they're able to use prompts, instead of exact client commands, and be oblivious to any MCP server being called.
- Adding an LLM to your client provides a better way for users to interact with MCP Servers.
- You need to convert the MCP Server response to something the LLM can understand.