Spring AI ve Model Context Protocol ile çalışan Java backend araçlarını; şema doğrulama, işlem sınırları, idempotency, JFR profilleme ve Testcontainers testleri üzerinden üretime hazırlayın.
Spring MCP ile Java Backend Geliştirmede Güvenli Araç Tasarımı
Spring MCP ve Model Context Protocol için araç sözleşmesi
Bir Model Context Protocol aracı HTTP denetleyicisi değildir: model, aracı aynı oturumda tekrar çağırabilir, parametreleri eksik gönderebilir ve yanıtı sonraki bir çağrıya bağlam olarak kullanır. Bu nedenle araç girdi/çıktısını JPA entity'siyle değil, sürümlenmiş DTO ile tanımlayın. Örneğin stok ayırma aracında quantity > 0, requestId zorunluluğu ve modelin yorumlamasını kolaylaştıran kapalı sonuç durumu açıkça şemada bulunmalıdır:
public record ReserveStockRequest(
@NotBlank String requestId,
@NotBlank String sku,
@Positive int quantity
) {}
public record ReserveStockResult(
Status status,
String reservationId,
String reason
) {
enum Status { RESERVED, OUT_OF_STOCK, DUPLICATE }
}Bu ayrım, entity'ye sonradan eklenen costPrice gibi alanların istemeden model bağlamına sızmasını engeller.Araç adını fiil-nesne biçiminde ve yan etkisini belirtecek şekilde seçin: inventory.reserve bir write işlemidir; inventory.getAvailability ise read işlemidir. Araç açıklamasına "stok ayırır" demek yerine requestId ile idempotenttir; yalnızca çağıranın tenant'ındaki SKU'yu değiştirir bilgisini koyun. Java microservices ortamında bu sözleşmeyi OpenAPI'den türetmeye çalışmak genellikle hatalıdır; MCP araç şeması modelin parametre üretmesi için optimize edilirken, servis API'si ağ geçidi, sayfalama ve hata zarfı gibi farklı kaygılar taşır.
Bu sınırı erken koymak, bir java eğitimi veya java programlama eğitimi sırasında atlanan önemli bir üretim ayrıntısını görünür kılar: LLM çağrısı deterministik bir kullanıcı formu değildir. Araç çağrısı kaydına yalnızca hash'lenmiş parametreleri, araç adını, tenant'ı, süreyi ve sonucu yazın; ham prompt ya da erişim belirteci yazmayın. Örneğin SHA-256 hash'i için MessageDigest.getInstance("SHA-256") ile kanonik JSON üzerinde hash üretin; alan sırası değiştiğinde yanlış idempotency eşleşmesi oluşmaması için Jackson'da SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS etkinleştirin.
Spring AI ile Spring MCP araçlarını Spring Framework'e bağlamak
Spring AI MCP sunucusu tarafında araç metodunu doğrudan controller'a koymak yerine iş kuralı sınıfında tutun ve açık bir ToolCallbackProvider bean'i verin. Bu yapı, araç keşfinin component tarama ayrıntılarına bağımlı kalmasını önler ve unit testte aynı sınıfın doğrudan çağrılmasını sağlar:
@Component
@RequiredArgsConstructor
class InventoryTools {
private final ReservationService reservations;
@Tool(description = "Reserves stock idempotently for the authenticated tenant")
ReserveStockResult reserve(@ToolParam ReserveStockRequest request) {
return reservations.reserve(request);
}
}
@Configuration
class McpToolsConfig {
@Bean
ToolCallbackProvider inventoryToolCallbacks(InventoryTools tools) {
return MethodToolCallbackProvider.builder()
.toolObjects(tools)
.build();
}
}Uygun Spring AI MCP server starter'ını proje bağımlılıklarına ekledikten sonra bu provider, sunucunun araç listesine yalnızca bu bean'deki metodları dahil eder.Yapılandırmada sunucu kimliğini sabitleyin ve taşıma katmanının istek üstbilgilerini kaybetmediğini entegrasyon testiyle doğrulayın. Örnek ayar aşağıdaki gibi tutulabilir; dağıtım topolojisine göre streamable HTTP veya başka desteklenen taşıma seçeneğini starter dokümantasyonundaki değerlerle eşleştirin:
spring:
ai:
mcp:
server:
name: inventory-tools
version: 1.0.0
management:
endpoints:
web:
exposure:
include: health,metrics,prometheusversion alanı uygulama sürümüdür; protokol uyumluluğu iddiası değildir. Proxy'nin response buffering yapması uzun ömürlü MCP bağlantılarında akışı bozabileceğinden, ingress ayarını seçtiğiniz taşımanın gerektirdiği streaming davranışıyla test edin.Spring framework güvenliği burada endpoint korumaktan fazlasıdır. Araç içinde tenant yetkisini tekrar doğrulayın; çünkü bir araç seçimi, modelin ürettiği argümanla yapılır. Metot güvenliği için @EnableMethodSecurity ve aşağıdaki kontrolü kullanın:
@PreAuthorize("hasAuthority('SCOPE_inventory.write')")
@Transactional
public ReserveStockResult reserve(ReserveStockRequest request) {
String tenant = TenantContext.requiredTenant();
return reserveForTenant(tenant, request);
}Özellikle async executor'a geçiyorsanız SecurityContext ve tenant bilgisinin thread-local'da otomatik taşınacağını varsaymayın; DelegatingSecurityContextTaskDecorator veya açık parametre aktarımı olmadan yetki bağlamı boş ya da yanlış kullanıcıya ait olabilir.Hibernate ORM ve Spring Data JPA ile idempotent yazma işlemleri
hibernate orm tarafında yalnızca @Version eklemek, araç tekrarlarını çözmez: aynı requestId ile iki ilk çağrı eşzamanlı gelirse iki farklı reservation satırı oluşabilir. spring data jpa repository'sinde tenant ve request kimliği için veritabanı seviyesinde benzersiz indeks tanımlayın; uygulama ön kontrolü yarış durumuna karşı yeterli değildir:
@Entity
@Table(name = "stock_reservation",
uniqueConstraints = @UniqueConstraint(
name = "uk_reservation_tenant_request",
columnNames = {"tenant_id", "request_id"}))
class StockReservation {
@Id UUID id;
@Version long version;
@Column(name = "tenant_id", nullable = false) String tenantId;
@Column(name = "request_id", nullable = false) String requestId;
}
interface ReservationRepository extends JpaRepository<StockReservation, UUID> {
Optional<StockReservation> findByTenantIdAndRequestId(String tenantId, String requestId);
}Bu indeks, farklı tenant'ların aynı istemci anahtarını kullanabilmesine izin verirken tenantlar arası sonuç sızıntısını engeller.Stok sayacını güncellerken önce entity okuyup Java'da azaltmak yerine koşullu tek SQL güncellemesi çalıştırın. Bu yaklaşım, iki transaction'ın aynı available değerini okuyup ikisinin de başarılı görünmesi sorununu veritabanı atomikliğine bırakır:
@Modifying
@Query("""
update Inventory i
set i.available = i.available - :quantity
where i.tenantId = :tenant and i.sku = :sku
and i.available >= :quantity
""")
int decrementIfAvailable(String tenant, String sku, int quantity);
@Transactional
public ReserveStockResult reserve(ReserveStockRequest r) {
if (repo.findByTenantIdAndRequestId(tenant(), r.requestId()).isPresent())
return new ReserveStockResult(DUPLICATE, null, "request already processed");
if (inventory.decrementIfAvailable(tenant(), r.sku(), r.quantity()) != 1)
return new ReserveStockResult(OUT_OF_STOCK, null, "insufficient stock");
try { return persistReservation(r); }
catch (DataIntegrityViolationException duplicate) { return duplicateResult(r); }
}Pratikte DataIntegrityViolationException transaction'ı rollback-only işaretleyebilir; bu yüzden duplicate yakalama ve mevcut kaydı okuma işlemini yeni bir transaction sınırında yürütün ya da veritabanınızın desteklediği INSERT ... ON CONFLICT/MERGE stratejisini native query ile tek adıma indirin.Reservation başarılı olduktan sonra başka bir servise olay gönderecekseniz broker publish işlemini transaction içinde doğrudan yapmayın. Aynı transaction'da outbox_event kaydı yazın, ardından ayrı bir worker ile yayınlayın; worker için select ... for update skip locked kullanan native sorgu birden fazla pod'un aynı olayı almasını önler. Bu ayrıntı, java backend geliştirme yapan ekiplerde sık görülen "model başarılı dedi ama downstream servis olayı almadı" tutarsızlığını, veritabanı commit'i ile publish arasında yeniden denenebilir bir kayıt bırakarak çözer.
Spring MCP araçlarında JFR ile gecikme ve N+1 profillemesi
Araç gecikmesini model sağlayıcısı süresiyle karıştırmayın. MCP sunucusunda inventory.reserve için ayrı bir Micrometer zamanlayıcısı kaydedin ve etiketlerde SKU, requestId veya kullanıcı kimliği kullanmayın; bunlar yüksek cardinality ile Prometheus belleğini büyütür. Araç adı ve sonuç durumu yeterlidir:
Timer timer = Timer.builder("mcp.tool.duration")
.tag("tool", "inventory.reserve")
.tag("outcome", result.status().name())
.register(meterRegistry);
timer.record(() -> service.reserve(request));management.metrics.distribution.percentiles-histogram.mcp.tool.duration=true ayarıyla p95/p99 histogramı çıkarın; model çağrısı varsa onu ayrı ai.client.duration metriğiyle ölçün.Önce-sonra karşılaştırmasını JFR ile gerçek yük altında yapın. JVM'e 120 saniyelik kayıt başlatmak için jcmd <pid> JFR.start name=mcp settings=profile duration=120s filename=/tmp/mcp.jfr komutunu çalıştırın; Java Mission Control içinde JDBC, Java Application ve Lock Instances görünümlerini inceleyin. Önce her araç çağrısı için JDBC execute sayısını ve mcp.tool.duration p95 değerini kaydedin; aşağıdaki entity döngüsü çağrı başına 1+N sorgu üretirse N+1 kanıtlanmış olur:
Order order = orderRepository.findById(id).orElseThrow();
for (OrderLine line : order.getLines()) { // LAZY koleksiyon
total += line.getProduct().getPrice(); // her product için ek select olabilir
}Düzeltme olarak yalnızca bu okuma yolu için fetch planı tanımlayın ve aynı JFR senaryosunu tekrar çalıştırın. join fetch ile birden fazla koleksiyon fetch etmek Cartesian çarpım üretebileceğinden, tek koleksiyon veya DTO projection tercih edin:
@Query("""
select new com.acme.api.OrderSummary(o.id, sum(l.quantity * p.price))
from Order o join o.lines l join l.product p
where o.id = :id
group by o.id
""")
Optional<OrderSummary> findSummary(UUID id);Sonraki kayıtta JDBC execute/call, toplam sorgu süresi ve p95'i önceki kayıtla karşılaştırın. Örneğin hedefi "çağrı başına 12 sorgudan 1 sorguya" diye koyun; yalnızca ortalama süreye bakmak, nadir lock beklemelerinin p99'u bozmasını gizler.Java fullstack eğitimi için test, yetki ve operasyon kontrol listesi
Gerçek veritabanı davranışını H2 ile doğrulamak yerine Testcontainers PostgreSQL kullanın; unique constraint, transaction isolation ve skip locked semantiği ancak hedef veritabanına yakın testte güvenilir olur. Paralel iki isteği aynı requestId ile başlatıp tek reservation doğrulayın:
try (PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres")) {
pg.start();
// Spring test datasource URL'sini pg.getJdbcUrl() ile bağlayın
var start = new CountDownLatch(1);
var calls = IntStream.range(0, 2).mapToObj(i ->
pool.submit(() -> { start.await(); return service.reserve(request); })
).toList();
start.countDown();
assertThat(repo.count()).isEqualTo(1);
}Ek olarak, aynı testte stok miktarının yalnızca bir kez düştüğünü assertion ile kontrol edin; yalnızca reservation satırını saymak yetersizdir.spring boot eğitimi materyallerinde araçların HTTP katmanı çoğu zaman ihmal edilir. MCP taşıma uç noktasına erişen kimlik doğrulanmış principal'ın tool metoduna ulaştığını bir entegrasyon testiyle doğrulayın; ardından SCOPE_inventory.write olmadan çağrının 403 ürettiğini test edin. Spring Security testinde @WithMockJwt(authorities = "SCOPE_inventory.write") ile pozitif, boş authority ile negatif senaryo yazın. Bu test, reverse proxy'nin Authorization üstbilgisini düşürmesi veya özel authentication converter'ın scope prefix'ini değiştirmesi gibi dağıtım hatalarını yakalar.
java kursu veya java fullstack eğitimi içinde bu mimariyi uygularken frontend'e modelin serbest metnini değil, ReserveStockResult içindeki kapalı Status değerlerini gönderin. React ya da başka bir istemci RESERVED, OUT_OF_STOCK ve DUPLICATE için ayrı UI durumu üretmelidir. Böylece araç açıklamasının ya da model yanıt biçiminin değişmesi, kullanıcı arayüzünde string eşleştirmeye bağlı kırılma yaratmaz; backend sözleşmesi JSON Schema testleriyle sürümlenebilir kalır.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring AI ve Spring MCP ile idempotent tool nasıl yazılır?
İstemciden zorunlu bir requestId alın, tenantId+requestId için veritabanında unique constraint oluşturun ve duplicate yarışını uygulama ön kontrolüyle değil bu constraint ile kesinleştirin. Stok gibi değişikliklerde koşullu UPDATE kullanın; duplicate exception sonrası sonucu aynı transaction içinde okumaya çalışmayın, rollback-only durumunu hesaba katın.
Java backend geliştirme projelerinde Model Context Protocol araçları nasıl yetkilendirilir?
MCP taşıma katmanında OAuth2/JWT doğrulaması yapın, ancak araç metodunda da @PreAuthorize ile gerekli scope'u kontrol edin. Tenant bilgisini JWT claim'inden üretin; modelin araç argümanından gelen tenantId değerini yetki kaynağı kabul etmeyin. Async iş varsa SecurityContext ve tenant bağlamını açıkça taşıyın.
Hibernate ORM ile Spring Data JPA N+1 problemi MCP araçlarında nasıl ölçülür?
Aynı sabit yük senaryosunda jcmd ile JFR profile kaydı alın, Java Mission Control'de JDBC execute sayısını ve sorgu sürelerini inceleyin. Önce p95 mcp.tool.duration ile çağrı başına SQL sayısını kaydedin; ardından DTO projection veya kontrollü join fetch uygulayıp aynı kayıt süresinde iki değeri karşılaştırın. Birden fazla koleksiyon için join fetch kullanmak Cartesian çarpım yaratabileceği için sonuç satır sayısını da ölçün.
AI / LLM Discovery
Bu makale Opendart Akademi Java eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.



