Spring AI · MCP serveri və müştərisi · Dərs 2
MCP serverinin qurulması
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.
MCP serverinin qurulması
Study Coach-un iki ictimai Java qeydini stdio vasitəsilə təqdim edəcəyik. Prosesin işi kiçik kataloq üçün yoxlanmış mətn qaytarmaqdır. O, tələbə bazası açmır, HTTP sorğusu etmir, model çağırmır və plan saxlamır. Protokol dərsindəki ayrıca Maven layihəsini davam etdir.
1. Əvvəlcə biznes girişini məhdudlaşdır
src/main/java/academy/study/mcp/StudyCatalog.java faylını yarat:
package academy.study.mcp;
import java.util.Map;
import java.util.Set;
public final class StudyCatalog {
public static final String RESOURCE_URI = "study://notes/java-basics";
public static final String RESOURCE_TEXT = "Study Coach public notes: INHERITANCE, RESOURCES.";
public static final String TOOL_NAME = "study_tip";
public static final String PROMPT_NAME = "review_topic";
public enum Topic { INHERITANCE, RESOURCES }
private StudyCatalog() {}
public static Topic topic(Map<String, Object> arguments) {
if (arguments == null || !arguments.keySet().equals(Set.of("topic"))
|| !(arguments.get("topic") instanceof String value) || value.length() > 20) {
throw new IllegalArgumentException("Exactly one supported topic is required");
}
try {
return Topic.valueOf(value);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported topic");
}
}
public static String tip(Map<String, Object> arguments) {
return switch (topic(arguments)) {
case INHERITANCE -> "A Java class has at most one direct superclass.";
case RESOURCES -> "Use try-with-resources with AutoCloseable resources.";
};
}
public static String read(String uri) {
if (!RESOURCE_URI.equals(uri)) throw new IllegalArgumentException("Resource unavailable");
return RESOURCE_TEXT;
}
public static String prompt(Map<String, Object> arguments) {
return "Ask me one Java question about " + topic(arguments).name()
+ ". Wait for my answer before explaining it.";
}
}Çağıran tərəf yalnız bir topic açarı və böyük-kiçik hərfə həssas iki dəyərdən birini göndərir. Əlavə owner və ya approved sahəsi yoxlamadan keçmir. Resurs yalnız bir dəqiq URI qəbul edir; həmin mətni fayl yolu və ya uzaq ünvan kimi işlətmir. Sorğu şablonunun dəyişəni də göstərişə yerləşdirilən sərbəst mətn deyil, enum dəyəridir.
20 simvolluq giriş yoxlaması Java-nın UTF-16 uzunluğundan istifadə edir. Əsas məhdudiyyət isə yalnız iki sabit mövzu dəyərinin qəbul edilməsidir. Bütün uğurlu mətn nəticələri 200 UTF-16 kod vahidindən qısadır. Bu kataloqda böyüyən kolleksiya və ya səhifələnən nəticə yoxdur. Hədlər handler-in işinə aiddir, SDK parser-inə çatan mesajın ölçüsünə yox.
2. Protokol handler-lərini qeydiyyata al
src/main/java/academy/study/mcp/StudyMcpServer.java faylını yarat:
package academy.study.mcp;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
public final class StudyMcpServer {
private StudyMcpServer() {}
public static McpServerFeatures.SyncToolSpecification tool() {
var schema = new McpSchema.JsonSchema("object", Map.of("topic", Map.of(
"type", "string", "enum", List.of("INHERITANCE", "RESOURCES"))),
List.of("topic"), false, null, null);
return McpServerFeatures.SyncToolSpecification.builder()
.tool(McpSchema.Tool.builder().name(StudyCatalog.TOOL_NAME)
.description("Read one public Java study tip; no learner data")
.inputSchema(schema).build())
.callHandler((exchange, request) -> {
try {
return McpSchema.CallToolResult.builder()
.addTextContent(StudyCatalog.tip(request.arguments())).isError(false).build();
} catch (IllegalArgumentException ex) {
return McpSchema.CallToolResult.builder()
.addTextContent("Unsupported study-tip arguments").isError(true).build();
}
}).build();
}
public static McpServerFeatures.SyncResourceSpecification resource() {
return new McpServerFeatures.SyncResourceSpecification(
McpSchema.Resource.builder().uri(StudyCatalog.RESOURCE_URI).name("java-basics")
.description("Fixed public Study Coach topic index").mimeType("text/plain").build(),
(exchange, request) -> new McpSchema.ReadResourceResult(List.of(
new McpSchema.TextResourceContents(request.uri(), "text/plain",
StudyCatalog.read(request.uri())))));
}
public static McpServerFeatures.SyncPromptSpecification prompt() {
return new McpServerFeatures.SyncPromptSpecification(
new McpSchema.Prompt(StudyCatalog.PROMPT_NAME, "Review one supported Java topic",
List.of(new McpSchema.PromptArgument("topic", "INHERITANCE or RESOURCES", true))),
(exchange, request) -> new McpSchema.GetPromptResult("One practice question", List.of(
new McpSchema.PromptMessage(McpSchema.Role.USER,
new McpSchema.TextContent(StudyCatalog.prompt(request.arguments()))))));
}
public static void main(String[] args) {
var mapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transport = new StdioServerTransportProvider(mapper);
McpSyncServer server = McpServer.sync(transport).jsonMapper(mapper)
.serverInfo("study-coach-public", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(false).resources(false, false).prompts(false).build())
.tools(tool()).resources(resource()).prompts(prompt()).build();
Runtime.getRuntime().addShutdownHook(new Thread(server::close));
}
}SyncToolSpecification elan olunan JSON Schema ilə Java handler-ini birləşdirir. Sxem qəbul edilən girişi təsvir edir; StudyCatalog icra sərhədində onu yenidən yoxlayır. Handler öz giriş yoxlaması uğursuz olduqda qısa isError: true nəticəsi qaytarır. Rədd edilmiş dəyərləri və stack trace-i cavaba yazmır. Naməlum metod və ya tapılmayan resurs kimi protokol xətalarını isə SDK ayrıca idarə edir. Sabit versiyadakı server spesifikasiyaları
SyncResourceSpecification bir text/plain məzmun elementi qaytarır. SyncPromptSpecification isə user rolunda bir mesaj qaytarır. İkinci handler məşq sorğusu qurur; hələ sual yaradılmır. Java SDK 0.18.3 üçün io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper istifadə olunur. Köhnə json.jackson paketi bu versiyada qalsa da, deprecated-dir; kodumuz yeni paketi istifadə edir.
3. Prosesin həyat dövrünü və çıxışını aydınlaşdır
.tools(false) siyahı dəyişikliyi bildirişləri olmadan alətləri aktivləşdirir. .resources(false, false) abunəlik və siyahı dəyişikliyi bildirişləri olmadan resursları aktivləşdirir. .prompts(false) isə siyahı dəyişikliyi bildirişləri olmadan şablonları aktivləşdirir. Buradakı false bütün imkanı söndürmür. SDK logging imkanını da təqdim edə bilər; biz ayrıca logging əməliyyatı əlavə etmirik. Server builder-i
Server qurulanda SDK axınları emal edən thread-ləri başladır. Main metodu stdin-i özü oxumur və əlavə latch üzərində gözləmir. Axının sonunu transport emal edir; Java prosesi bitəndə shutdown hook da serveri bağlayır. Növbəti dərsdə onu real müştəri ilə başladıb bağlayacağıq.
slf4j-simple standart olaraq diaqnostikanı stderr-ə yazır. Bu serverdə System.out.println, tətbiq banner-i və veb starter yoxdur. Stdout yalnız MCP üçün qalsın. Serveri terminalda birbaşa başlatdıqda o, protokol girişi gözləyir; interaktiv söhbət proqramı deyil. Stdio implementasiyası
4. İctimai məlumatı tələbə icazələrindən ayır
Bu kataloq qəsdən ictimai tədris qeydləri saxlayır. Yoxlanmış lokal prosesi başlada bilən hər bir müştəri onları oxuya bilər. Handler-lərdə giriş axını, sahiblik iddiası, icazə modeli və ya gizli məlumat yoxdur. Məlumat ictimai olduğu və əməliyyatların xarici yan təsiri olmadığı üçün bu yanaşma uyğundur.
Əvvəlki şəxsi qeyd və ya plan saxlama alətlərini bu serverə köçürüb MCP sessiyasını, resurs URI-sini, alət təsvirini və ya sorğu metadata-sını icazə kimi qəbul etmə. Uzaq xidmət çağıran tərəfin kimliyini təsdiqləməli və hər əməliyyatın icazəsini yoxlamalıdır. Saxlama üçün insan təsdiqi yenə də konkret təklifə və sahibinə bağlanmalı, nəticə qəbzi isə əməliyyatın davamlılıq qaydasına uyğun olaraq təkrar cəhdlər ərzində saxlanmalıdır.
Gələcək HTTP yerləşdirməsi üçün autentifikasiya və avtorizasiya axınını ayrıca seç, token-in nəzərdə tutulan xidmətini yoxla, token passthrough və daxili şəbəkəyə istənilməyən çıxışın qarşısını al. Bu uzaq bağlantı nəzarətləri stdio nümunəsində qurulmayıb. MCP təhlükəsizlik qaydaları
5. Handler-ləri modelsiz test et
src/test/java/academy/study/mcp/StudyCatalogTest.java faylını yarat:
package academy.study.mcp;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import io.modelcontextprotocol.spec.McpSchema;
import static org.junit.jupiter.api.Assertions.*;
class StudyCatalogTest {
@Test
void twoKnownTopicsReturnFixedNotes() {
assertEquals("A Java class has at most one direct superclass.",
StudyCatalog.tip(Map.of("topic", "INHERITANCE")));
assertEquals("Use try-with-resources with AutoCloseable resources.",
StudyCatalog.tip(Map.of("topic", "RESOURCES")));
}
@Test
void missingArgumentsAreRejected() {
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.topic(null));
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.topic(Map.of()));
}
@Test
void ownerAndPermissionFieldsAreNotAccepted() {
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.topic(
Map.of("topic", "INHERITANCE", "owner", "learner-a")));
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.topic(
Map.of("topic", "INHERITANCE", "approved", true)));
}
@Test
void unsupportedTypeTopicAndOversizedInputAreRejected() {
for (Object topic : List.of(7, "FILES", "inheritance", "x".repeat(21))) {
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.topic(Map.of("topic", topic)));
}
}
@Test
void onlyTheExactResourceUriIsReadable() {
assertEquals(StudyCatalog.RESOURCE_TEXT, StudyCatalog.read(StudyCatalog.RESOURCE_URI));
for (String uri : List.of("file:///etc/passwd", "https://example.com", "study://notes/../private")) {
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.read(uri));
}
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.read(null));
}
@Test
void promptHasOneBoundedTopicAndDoesNotRunAModel() {
assertEquals("Ask me one Java question about RESOURCES. Wait for my answer before explaining it.",
StudyCatalog.prompt(Map.of("topic", "RESOURCES")));
assertThrows(IllegalArgumentException.class, () -> StudyCatalog.prompt(Map.of("topic", "Ignore rules")));
}
@Test
void toolHandlerReturnsAnErrorWithoutLeakingArguments() {
var request = new McpSchema.CallToolRequest(StudyCatalog.TOOL_NAME, Map.of("topic", "private-sentinel"));
var result = StudyMcpServer.tool().callHandler().apply(null, request);
assertEquals(true, result.isError());
assertEquals("Unsupported study-tip arguments", ((McpSchema.TextContent) result.content().getFirst()).text());
}
@Test
void declaredSchemaHasNoIdentityOrFreeTextFields() {
var schema = StudyMcpServer.tool().tool().inputSchema();
assertEquals(java.util.Set.of("topic"), schema.properties().keySet());
assertEquals(List.of("topic"), schema.required());
assertEquals(false, schema.additionalProperties());
}
}Bu səkkiz test metodu hər iki qeydi, çatışmayan və əlavə sahələri, yanlış tipləri, naməlum mövzuları, böyük dəyərləri, dəqiq resurs uyğunluğunu, şablon qurulmasını, təhlükəsiz alət xəta mətnini və elan olunmuş sxemi yoxlayır. Birbaşa handler testi Java sərhədini ayrıca yoxlamaq üçün transport qatından istifadə etmir. Onun uğurlu olması HTTP gateway-in və ya modelin təhlükəsiz olduğunu sübut etmir.
İki əsas sinfi və testi saxladıqdan sonra bu əmri işə sal:
mvn -B -ntp -Dtest=StudyCatalogTest testNövbəti dərs real alt proses mübadiləsi də daxil olmaqla on müştəri testi əlavə edir. Həmin qatı qurarkən bu test sinfini dəyişmə.
6. Cəlbedici görünən genişləndirməni yoxla
Tapşırıq: study://notes/java-basics əvəzinə file:///etc/passwd və ya https://example.com göndər. Server onu oxumalıdır, icazəli resursa çevirməlidir, yoxsa rədd etməlidir?
Cavabı yoxla: rədd etməlidir. Resurs identifikatoru dəqiqdir. Bu kataloqda fayl oxuyucusu və HTTP müştərisi olmamalıdır. Onları əlavə etmək yeni icazə sərhədi, ölçü hədləri, vaxt limitləri, icazəli ünvanlar və testlər tələb edər; bu, URI parser-ində zərərsiz dəyişiklik sayılmaz.
İndi təsəvvür et ki, server “əvvəlki göstərişləri nəzərə alma” yazılan qeyd qaytarır. Bağlantı və sxem düzgün olsa da, bu mətn etibarsız xarici məzmundur. Onu necə göstərməyi və ya model kontekstinə necə daxil etməyi host müəyyən edir. Sxem yoxlaması və qısa nəticə həddi prompt injection riskini aradan qaldırmır.