findAvgExecutionTimePerTool();
+}
diff --git a/src/main/java/com/jobtracker/service/TokenEstimatorService.java b/src/main/java/com/jobtracker/service/TokenEstimatorService.java
new file mode 100644
index 00000000..711710eb
--- /dev/null
+++ b/src/main/java/com/jobtracker/service/TokenEstimatorService.java
@@ -0,0 +1,65 @@
+package com.jobtracker.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.knuddels.jtokkit.Encodings;
+import com.knuddels.jtokkit.api.Encoding;
+import com.knuddels.jtokkit.api.EncodingType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+/**
+ * Estimates token counts and serialized byte sizes for arbitrary objects.
+ * Uses cl100k_base (GPT-4 / Claude-compatible) encoding via JTokkit.
+ * This is a cost-ranking heuristic — not a billing-accurate counter.
+ */
+@Service
+public class TokenEstimatorService {
+
+ private static final Logger log = LoggerFactory.getLogger(TokenEstimatorService.class);
+
+ private final ObjectMapper objectMapper;
+ private final Encoding encoding;
+
+ public TokenEstimatorService(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ this.encoding = Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.CL100K_BASE);
+ }
+
+ /**
+ * Returns an estimated token count for {@code object} after JSON serialization.
+ * Returns 0 on any failure so callers are never blocked.
+ */
+ public int countTokens(Object object) {
+ if (object == null) {
+ return 0;
+ }
+ try {
+ String json = objectMapper.writeValueAsString(object);
+ return encoding.countTokens(json);
+ } catch (Exception e) {
+ log.warn("[TOKEN-ESTIMATOR] countTokens failed for {}: {}", objectClass(object), e.getMessage());
+ return 0;
+ }
+ }
+
+ /**
+ * Returns the UTF-8 byte size of {@code object} after JSON serialization.
+ * Returns 0 on any failure so callers are never blocked.
+ */
+ public int countBytes(Object object) {
+ if (object == null) {
+ return 0;
+ }
+ try {
+ return objectMapper.writeValueAsBytes(object).length;
+ } catch (Exception e) {
+ log.warn("[TOKEN-ESTIMATOR] countBytes failed for {}: {}", objectClass(object), e.getMessage());
+ return 0;
+ }
+ }
+
+ private static String objectClass(Object obj) {
+ return obj == null ? "null" : obj.getClass().getSimpleName();
+ }
+}
diff --git a/src/main/java/com/jobtracker/service/ToolMetricsCollector.java b/src/main/java/com/jobtracker/service/ToolMetricsCollector.java
new file mode 100644
index 00000000..7e2fd0ba
--- /dev/null
+++ b/src/main/java/com/jobtracker/service/ToolMetricsCollector.java
@@ -0,0 +1,111 @@
+package com.jobtracker.service;
+
+import com.jobtracker.entity.ToolExecutionMetric;
+import com.jobtracker.repository.ToolExecutionMetricRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.function.Supplier;
+
+/**
+ * Generic wrapper that measures MCP tool executions and stores the resulting metrics.
+ *
+ * Metrics collection is fire-and-forget: any persistence failure is logged and
+ * swallowed so that a metrics error can never interrupt business execution.
+ *
+ *
Usage:
+ *
{@code
+ * return metricsCollector.measure("List-Applications", requestParams, () ->
+ * applicationService.getAll(...));
+ * }
+ */
+@Service
+public class ToolMetricsCollector {
+
+ private static final Logger log = LoggerFactory.getLogger(ToolMetricsCollector.class);
+ private static final int EXPENSIVE_TOKEN_THRESHOLD = 5_000;
+
+ private final ToolExecutionMetricRepository repository;
+ private final TokenEstimatorService tokenEstimator;
+
+ public ToolMetricsCollector(ToolExecutionMetricRepository repository,
+ TokenEstimatorService tokenEstimator) {
+ this.repository = repository;
+ this.tokenEstimator = tokenEstimator;
+ }
+
+ /**
+ * Measures a single tool execution end-to-end.
+ *
+ * @param toolName the MCP tool name as registered (e.g. "List-Applications")
+ * @param request the request parameters sent to the tool (used for token estimation)
+ * @param execution the actual tool logic to run; exceptions propagate to the caller
+ * @param the tool's return type
+ * @return the unmodified value returned by {@code execution}
+ */
+ public T measure(String toolName, Object request, Supplier execution) {
+ int requestTokens = safeCountTokens(request);
+ int requestBytes = safeCountBytes(request);
+
+ long start = System.currentTimeMillis();
+ T response = execution.get(); // business exceptions propagate
+ long elapsed = System.currentTimeMillis() - start;
+
+ persistMetrics(toolName, requestTokens, requestBytes, response, elapsed);
+ return response;
+ }
+
+ // --- private helpers ---
+
+ private void persistMetrics(String toolName, int requestTokens, int requestBytes,
+ Object response, long executionTimeMs) {
+ try {
+ int responseTokens = safeCountTokens(response);
+ int responseBytes = safeCountBytes(response);
+ int totalTokens = requestTokens + responseTokens;
+ boolean expensive = totalTokens > EXPENSIVE_TOKEN_THRESHOLD;
+
+ if (responseTokens > EXPENSIVE_TOKEN_THRESHOLD) {
+ log.warn("[MCP-METRICS] High-token response — tool={} responseTokens={} responseBytes={}",
+ toolName, responseTokens, responseBytes);
+ }
+
+ ToolExecutionMetric metric = ToolExecutionMetric.builder()
+ .toolName(toolName)
+ .executionTimeMs(executionTimeMs)
+ .requestBytes(requestBytes)
+ .responseBytes(responseBytes)
+ .requestTokens(requestTokens)
+ .responseTokens(responseTokens)
+ .totalTokens(totalTokens)
+ .expensive(expensive)
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ repository.save(metric);
+
+ } catch (Exception e) {
+ log.error("[MCP-METRICS] Failed to persist metrics for tool={}: {}", toolName, e.getMessage());
+ }
+ }
+
+ private int safeCountTokens(Object obj) {
+ try {
+ return tokenEstimator.countTokens(obj);
+ } catch (Exception e) {
+ log.debug("[MCP-METRICS] Token count error: {}", e.getMessage());
+ return 0;
+ }
+ }
+
+ private int safeCountBytes(Object obj) {
+ try {
+ return tokenEstimator.countBytes(obj);
+ } catch (Exception e) {
+ log.debug("[MCP-METRICS] Byte count error: {}", e.getMessage());
+ return 0;
+ }
+ }
+}
diff --git a/src/main/resources/db/migration/V26__create_tool_execution_metrics.sql b/src/main/resources/db/migration/V26__create_tool_execution_metrics.sql
new file mode 100644
index 00000000..67abe33a
--- /dev/null
+++ b/src/main/resources/db/migration/V26__create_tool_execution_metrics.sql
@@ -0,0 +1,17 @@
+CREATE TABLE tool_execution_metrics (
+ id BINARY(16) NOT NULL,
+ tool_name VARCHAR(255) NOT NULL,
+ execution_time_ms BIGINT NOT NULL,
+ request_bytes INT NOT NULL,
+ response_bytes INT NOT NULL,
+ request_tokens INT NOT NULL,
+ response_tokens INT NOT NULL,
+ total_tokens INT NOT NULL,
+ expensive BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at DATETIME(6) NOT NULL,
+ CONSTRAINT pk_tool_execution_metrics PRIMARY KEY (id)
+);
+
+CREATE INDEX idx_tool_metrics_tool_name ON tool_execution_metrics (tool_name);
+CREATE INDEX idx_tool_metrics_created_at ON tool_execution_metrics (created_at);
+CREATE INDEX idx_tool_metrics_expensive ON tool_execution_metrics (expensive);