Spring AI · MCP serveri və müştərisi · Dərs 3
Spring AI tətbiqində MCP müştərisi
Model Context Protocol (MCP) serverində alətləri, resursları və sorğu şablonlarını birləşdir. Tətbiqdə MCP müştərisi quraraq serverdən istifadə et.
Spring AI tətbiqində MCP müştərisi
Server artıq ayrıca Java prosesidir. Study Coach həmin yoxlanmış proqramı başladacaq, bir bağlantı hazırlayacaq, yeganə aləti kəşf edəcək və kataloqun üç əməliyyatını çağıracaq. Spring AI callback adapteri MCP alətini əvvəlki modulda istifadə etdiyimiz ToolCallback interfeysi ilə təqdim edir. Bu test çağırışlarını model seçmir və icra etmir.
1. Prosesi və bağlantını özün idarə et
Əvvəlki iki dərsdəki Java 21 Maven layihəsini və server siniflərini istifadə et. src/main/java/academy/study/mcp/StudyMcpClient.java faylını yarat:
package academy.study.mcp;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.ai.mcp.SyncMcpToolCallback;
import org.springframework.ai.tool.ToolCallback;
public final class StudyMcpClient implements AutoCloseable {
final McpSyncClient connection;
private final ToolCallback tip;
private final String protocolVersion;
public StudyMcpClient(String trustedClassPath) {
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
var parameters = ServerParameters.builder(java).args("-cp", trustedClassPath,
StudyMcpServer.class.getName()).build();
parameters.getEnv().clear();
var transport = new StdioClientTransport(parameters, new JacksonMcpJsonMapper(new ObjectMapper())) {
@Override
protected ProcessBuilder getProcessBuilder() {
var process = new ProcessBuilder();
process.environment().clear();
return process;
}
};
connection = McpClient.sync(transport).initializationTimeout(Duration.ofSeconds(5))
.requestTimeout(Duration.ofSeconds(3))
.capabilities(McpSchema.ClientCapabilities.builder().build()).build();
try {
var initialized = connection.initialize();
protocolVersion = initialized.protocolVersion();
var capabilities = initialized.capabilities();
if (capabilities.tools() == null || capabilities.resources() == null || capabilities.prompts() == null) {
throw new IllegalStateException("Required MCP capabilities unavailable");
}
var tools = connection.listTools();
if (tools.nextCursor() != null || tools.tools().size() != 1) {
throw new IllegalStateException("Unexpected tool catalog");
}
var advertised = tools.tools().getFirst();
if (!StudyCatalog.TOOL_NAME.equals(advertised.name())
|| !StudyMcpServer.tool().tool().inputSchema().equals(advertised.inputSchema())) {
throw new IllegalStateException("Unexpected tool contract");
}
tip = SyncMcpToolCallback.builder().mcpClient(connection).tool(advertised)
.prefixedToolName("study_coach_study_tip")
.toolContextToMcpMetaConverter(context -> Map.of()).build();
} catch (RuntimeException ex) {
connection.closeGracefully();
throw ex;
}
}
public String protocolVersion() {
return protocolVersion;
}
public String tip(StudyCatalog.Topic topic) {
Objects.requireNonNull(topic, "Topic required");
String content = tip.call("{\"topic\":\"" + topic.name() + "\"}");
try {
var parsed = new ObjectMapper().readTree(content);
if (!parsed.isArray() || parsed.size() != 1 || !parsed.get(0).isObject() || parsed.get(0).size() != 1
|| !parsed.get(0).path("text").isTextual()) {
throw new IllegalStateException("Unexpected tool result");
}
return checkedText(parsed.get(0).path("text").asText());
} catch (java.io.IOException ex) {
throw new IllegalStateException("Invalid tool result", ex);
}
}
public String notes() {
var result = connection.readResource(new McpSchema.ReadResourceRequest(StudyCatalog.RESOURCE_URI));
if (result.contents().size() != 1
|| !(result.contents().getFirst() instanceof McpSchema.TextResourceContents text)
|| !StudyCatalog.RESOURCE_URI.equals(text.uri()) || !"text/plain".equals(text.mimeType())) {
throw new IllegalStateException("Unexpected resource result");
}
return checkedText(text.text());
}
public String reviewPrompt(StudyCatalog.Topic topic) {
Objects.requireNonNull(topic, "Topic required");
var result = connection.getPrompt(new McpSchema.GetPromptRequest(
StudyCatalog.PROMPT_NAME, Map.of("topic", topic.name())));
if (result.messages().size() != 1 || result.messages().getFirst().role() != McpSchema.Role.USER
|| !(result.messages().getFirst().content() instanceof McpSchema.TextContent text)) {
throw new IllegalStateException("Unexpected prompt result");
}
return checkedText(text.text());
}
static String checkedText(String text) {
if (text == null || text.isBlank() || text.length() > 200) {
throw new IllegalStateException("Unexpected text length");
}
return text;
}
@Override
public void close() {
connection.closeGracefully();
}
public static void main(String[] args) {
try (var client = new StudyMcpClient(System.getProperty("java.class.path"))) {
System.out.println("protocol=" + client.protocolVersion());
System.out.println(client.tip(StudyCatalog.Topic.INHERITANCE));
System.out.println(client.notes());
System.out.println(client.reviewPrompt(StudyCatalog.Topic.RESOURCES));
}
}
}Java proqramının yolu işləyən JVM-dən götürülür. Main sinfi sabitdir, classpath isə tətbiqin etibarlı başlanğıc konfiqurasiyasından gəlir. Heç biri model cavabından, şablon arqumentindən və ya istifadəçinin göndərdiyi əmrdən alınmır. Alt proses təmizlənmiş mühit alır: həm SDK parametr xəritəsi, həm də ProcessBuilder vasitəsilə miras alınan mühit təmizlənir. SDK-nın bu versiyasında təkcə boş mühit xəritəsi vermək miras alınan proses mühitini silməzdi. Stdio müştəri implementasiyası
Bu mühit idarəetməsi lokal Java laboratoriyası üçündür, müxtəlif əməliyyat sistemlərində sandbox yaratmır. Alt proses yenə cari hesabın əməliyyat sistemi icazələrinə malikdir. Başlanğıc əlaqələndirmə üçün beş saniyə, sonrakı sorğular üçün üç saniyə vaxt limiti var. Timeout nəticəni gözləməyi dayandırır, amma uzaq əməliyyatın ləğv edildiyini və ya geri qaytarıldığını sübut etmir. Əməliyyatlarımız yalnız oxuyur. Yazma əməliyyatında təkrar cəhddən əvvəl davamlı nəticə qeydiyyatı lazım olardı.
2. Aləti uyğunlaşdırmazdan əvvəl kataloqu yoxla
Konstruktor bağlantını hazırlayır və alət, resurs, şablon imkanlarını yoxlayır. Dəqiq bir alət, əlavə səhifənin olmaması, dəqiq ad və yoxlanmış giriş sxemi gözlənilir. Kataloq dəyişəndə və ya genişlənəndə əlavə funksiyaları səssizcə təqdim etmək əvəzinə bağlantının qurulması uğursuz olur. Bu konkret server üçün sxem bərabərliyi qəsdən sərtdir. Daha geniş inteqrasiya ayrıca uyğunluq və yoxlama qaydası tələb edir.
SyncMcpToolCallback.builder() yalnız həmin aləti uyğunlaşdırır. study_coach_study_tip host-da görünən addır; adapter serverə yenə study_tip göndərir. Prefiks təsadüfi ad toqquşmasını azaldır, amma server kimliyini sübut etmir. Bu nümunədə etibar serverin elan etdiyi addan deyil, yoxlanmış proqram və konfiqurasiya sərhədindən gəlir. Spring AI callback adapteri
Metadata çeviricisi boş xəritə qaytarır. Əvvəlki ToolSession, tələbə kimliyi və ya icazələr MCP metadata-sına çevrilmir. Server ictimai qeydlər təqdim edir. Callback-i sonradan model çağırışlarını idarə edən koda qoşsan, əvvəlki icazəli alət siyahısını, arqument həddini və söndürülmüş avtomatik icra dövrünü saxla. Callback qeydiyyatı modelin hər təklifini icra etmək təsdiqi deyil.
3. Üç nəticəni fərqli yoxla
tip() Spring AI callback-ini dəqiq bir dəfə çağırır. MCP alət nəticəsi isError: true olduqda adapter exception atır. Uğurlu halda bu versiya məzmun siyahısını JSON-a çevirir; mətn elementimiz üçün bu, text sahəli bir obyektdən ibarət massivdir. Adapterin bu təqdimatında xam MCP mesajındakı type ayırıcısı yoxdur. Onu tam JSON-RPC cavabı saymaq əvəzinə real adapter nəticəsini yoxlayırıq.
notes() SDK-nın resurs API-sindən istifadə edib element sayını, məzmun tipini, URI-ni və MIME tipini yoxlayır. reviewPrompt() gözlənilən user rolunda bir mətn mesajı alındığını yoxlayır. Sonra müştəri hər mətnin boş olmadığını və 200 UTF-16 kod vahidini keçmədiyini yoxlayır. Bu yoxlamalar deserializasiyadan sonra işləyir; transport səviyyəsində yaddaş həddi deyil.
Şablonu almaq onu modelə göndərmir. Resursu almaq mətni söhbətə əlavə etmir. Burada söhbət yaddaşı yenilənmir, provayder çağırılmır, avtomatik əlavə cavab yaradılmır və plan dəyişmir. Gələcəkdə host uzaq məzmundan istifadə etsə, onu etibarsız məlumat kimi qəbul etməli və hansı kontekstə daxil edildiyini idarə etməlidir.
4. Real lokal mübadiləni test et
src/test/java/academy/study/mcp/StudyMcpClientTest.java faylını yarat:
package academy.study.mcp;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import static org.junit.jupiter.api.Assertions.*;
@Timeout(15)
class StudyMcpClientTest {
private static StudyMcpClient client;
@BeforeAll
static void startLocalServer() {
String classPath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
client = new StudyMcpClient(classPath);
}
@AfterAll
static void stopLocalServer() {
if (client != null) client.close();
}
@Test
void initializesAndNegotiatesThePinnedStdioVersion() {
assertEquals("2024-11-05", client.protocolVersion());
assertTrue(client.connection.isInitialized());
var capabilities = client.connection.getServerCapabilities();
assertNotNull(capabilities.tools());
assertNotNull(capabilities.resources());
assertNotNull(capabilities.prompts());
assertEquals(false, capabilities.resources().subscribe());
assertNull(client.connection.getClientCapabilities().sampling());
assertNull(client.connection.getClientCapabilities().elicitation());
}
@Test
void discoveryListsOnlyTheThreeReviewedEntries() {
var tools = client.connection.listTools();
assertNull(tools.nextCursor());
assertEquals(Set.of(StudyCatalog.TOOL_NAME), tools.tools().stream()
.map(McpSchema.Tool::name).collect(Collectors.toSet()));
var resources = client.connection.listResources();
assertNull(resources.nextCursor());
assertEquals(Set.of(StudyCatalog.RESOURCE_URI), resources.resources().stream()
.map(McpSchema.Resource::uri).collect(Collectors.toSet()));
var prompts = client.connection.listPrompts();
assertNull(prompts.nextCursor());
assertEquals(Set.of(StudyCatalog.PROMPT_NAME), prompts.prompts().stream()
.map(McpSchema.Prompt::name).collect(Collectors.toSet()));
}
@Test
void springAiCallbackRunsTheRemoteReadOnlyTool() {
assertEquals("A Java class has at most one direct superclass.", client.tip(StudyCatalog.Topic.INHERITANCE));
assertEquals("Use try-with-resources with AutoCloseable resources.", client.tip(StudyCatalog.Topic.RESOURCES));
}
@Test
void rawToolResponseHasAnExplicitSuccessFlag() {
var result = client.connection.callTool(new McpSchema.CallToolRequest(
StudyCatalog.TOOL_NAME, Map.of("topic", "INHERITANCE")));
assertEquals(false, result.isError());
assertEquals(1, result.content().size());
assertInstanceOf(McpSchema.TextContent.class, result.content().getFirst());
}
@Test
void exactResourceReturnsPublicText() {
assertEquals(StudyCatalog.RESOURCE_TEXT, client.notes());
}
@Test
void promptReturnsTextWithoutModelGeneration() {
assertEquals("Ask me one Java question about RESOURCES. Wait for my answer before explaining it.",
client.reviewPrompt(StudyCatalog.Topic.RESOURCES));
}
@Test
void unknownResourceAndPromptFailWithoutFallback() {
assertThrows(RuntimeException.class, () -> client.connection.readResource(
new McpSchema.ReadResourceRequest("study://notes/private")));
assertThrows(RuntimeException.class, () -> client.connection.getPrompt(
new McpSchema.GetPromptRequest("save_plan", Map.of())));
}
@Test
void unsupportedPromptArgumentsAreRejectedAcrossTheTransport() {
assertThrows(RuntimeException.class, () -> client.connection.getPrompt(
new McpSchema.GetPromptRequest(StudyCatalog.PROMPT_NAME, Map.of("topic", "FILES"))));
}
@Test
void clientRejectsMissingTopicsAndUnboundedResultText() {
assertThrows(NullPointerException.class, () -> client.tip(null));
assertThrows(NullPointerException.class, () -> client.reviewPrompt(null));
assertThrows(IllegalStateException.class, () -> StudyMcpClient.checkedText(null));
assertThrows(IllegalStateException.class, () -> StudyMcpClient.checkedText(" "));
assertThrows(IllegalStateException.class, () -> StudyMcpClient.checkedText("x".repeat(201)));
assertEquals("x".repeat(200), StudyMcpClient.checkedText("x".repeat(200)));
}
@Test
void initializationTimeoutRejectsASilentChild() {
var parameters = io.modelcontextprotocol.client.transport.ServerParameters.builder(
java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString())
.args("-cp", System.getProperty("surefire.test.class.path"), SilentServer.class.getName()).build();
parameters.getEnv().clear();
var transport = new io.modelcontextprotocol.client.transport.StdioClientTransport(parameters,
new io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper(
new com.fasterxml.jackson.databind.ObjectMapper())) {
@Override
protected ProcessBuilder getProcessBuilder() {
var process = new ProcessBuilder();
process.environment().clear();
return process;
}
};
var silent = io.modelcontextprotocol.client.McpClient.sync(transport)
.initializationTimeout(java.time.Duration.ofMillis(300)).build();
try {
assertTimeoutPreemptively(java.time.Duration.ofSeconds(5),
() -> assertThrows(RuntimeException.class, silent::initialize));
} finally {
silent.closeGracefully();
}
}
public static class SilentServer {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(60_000);
}
}
}Ortaq test hazırlığı real serverin main sinfini stdio vasitəsilə başladır. Testlər başlanğıc mübadiləni, kəşf edilən üç elementi, hər iki alət nəticəsini, xam alət cavabındakı uğur göstəricisini, resurs və şablon məzmununu, naməlum sorğuların və yanlış şablon arqumentlərinin rədd edilməsini, müştəri hədlərini yoxlayır. Ayrıca səssiz alt proses başlanğıc vaxt limitini yoxlayır. HTTP stub-u və provayder hesabı lazım deyil.
Əvvəlki səkkiz handler testi ilə bu on müştəri testi birlikdə on səkkizdir. Testlər OAuth davranışını, çoxistifadəçili məlumat sahibliyini, zərərli mesajların ölçü hədlərini, fərqli dillərdə yazılmış SDK-ların uyğunluğunu və ya modelin daim düzgün alət seçməsini sübut etmir. Bunlar ayrı müqavilələrdir.
5. Müştərini işə sal və nəticəni yoxla
Java 21 seçilmiş halda laboratoriya qovluğunda işə sal:
mvn -B -ntp clean verify
mvn -B -ntp dependency:build-classpath -Dmdep.outputFile=target/mcp-classpath.txt
java -cp "target/classes:$(cat target/mcp-classpath.txt)" academy.study.mcp.StudyMcpClientSon əmr POSIX shell və : classpath ayırıcısından istifadə edir. Gözlənilən standart çıxış:
protocol=2024-11-05
A Java class has at most one direct superclass.
Study Coach public notes: INHERITANCE, RESOURCES.
Ask me one Java question about RESOURCES. Wait for my answer before explaining it.SDK diaqnostikası stderr-dədir. Müştəri bağlananda alt proses də dayandırılır; SDK-nın bu transport versiyası POSIX sistemində dayandırma siqnalından sonra 143 çıxış kodunu loqa yaza bilər. Bu, müştərinin uğurlu çıxışından və yoxlanmış nəticələrdən ayrıdır. Resurslu try və testin təmizləmə mərhələsi tətbiq xətalarından sonra da bağlantını bağlayır. Real mühitdə proses nəzarətçisi dayandırma siqnalına cavab verməyən alt proses üçün ayrıca qaydaya malik olmalıdır; nümunə məcburi dayandırma ehtiyatı qurmur. Transport-un bağlanması
Maven asılılıqları və plugin-ləri keşlədikdən sonra mvn -o clean verify eyni on səkkiz testi repozitoriyalara çıxmadan işlədir. Bu, ayrıca MCP laboratoriyasıdır; əvvəlki tətbiqin ümumi test sayını əvəz etmir. Yuxarıdakı stdout lokal Java serverindən gəlib, LLM-dən yox.
6. Kataloq dəyişəndə davranışı müəyyən et
Tapşırıq: serverə ikinci alət əlavə et, sonra bu müştərini yenidən yarat. Model kataloqu görməzdən əvvəl nə baş verməlidir? Ardınca resurs oxumazdan əvvəl alt prosesi dayandır və host-un tələbəyə hansı nəticəni deyə biləcəyini izah et.
Cavabı yoxla: müştəri qurularkən gözlənilməz alət sayını rədd edir və bağlantını bağlayır. Alt proses yox olarsa, sorğu xəta ilə bitir və ya vaxt limitinə çatır; host çatışmayan qeydi uydurmamalıdır. Yalnız məhdud cəhd qaydası ilə yenidən bağlan və əlavə əməliyyatları təqdim etməzdən əvvəl yeni kataloqu yoxla.
Axın artıq konkretdir: etibarlı başlanğıc, protokolun hazırlanması, yoxlanmış kataloq, açıq əməliyyat çağırışı, yoxlanmış nəticə və bağlantının bağlanması. Sonradan model əlavə etmək çağırışın necə təklif olunduğunu dəyişir. Bu sərhədləri aradan qaldırmır və uzaq mətni icazə mənbəyinə çevirmir.