44
55import io .modelcontextprotocol .spec .McpSchema ;
66import java .util .List ;
7+ import java .util .Locale ;
78import java .util .Map ;
9+ import java .util .function .Predicate ;
810import lombok .extern .slf4j .Slf4j ;
11+ import org .openmetadata .schema .entity .app .mcp .McpToolCallUsage ;
912import org .openmetadata .schema .utils .JsonUtils ;
1013import org .openmetadata .service .limits .Limits ;
1114import org .openmetadata .service .security .AuthorizationException ;
@@ -32,6 +35,22 @@ public McpSchema.CallToolResult callTool(
3235 String toolName ,
3336 CatalogSecurityContext securityContext ,
3437 McpSchema .CallToolRequest request ) {
38+ return callToolWithMetadata (authorizer , limits , toolName , securityContext , request ).result ();
39+ }
40+
41+ /**
42+ * Phase 3 entry point. Returns the tool result alongside the metadata the {@link
43+ * org.openmetadata.mcp.usage.McpUsageRecorder} needs (latency + error category). Kept as a
44+ * separate method so the legacy single-result signature stays available for external callers
45+ * that haven't migrated yet.
46+ */
47+ public CallToolOutcome callToolWithMetadata (
48+ Authorizer authorizer ,
49+ Limits limits ,
50+ String toolName ,
51+ CatalogSecurityContext securityContext ,
52+ McpSchema .CallToolRequest request ) {
53+ long startNanos = System .nanoTime ();
3554 LOG .info (
3655 "Catalog Principal: {} is trying to call the tool: {}" ,
3756 securityContext .getUserPrincipal ().getName (),
@@ -97,47 +116,159 @@ public McpSchema.CallToolResult callTool(
97116 result = new CreateDataProductTool ().execute (authorizer , limits , securityContext , params );
98117 break ;
99118 default :
100- return McpSchema .CallToolResult .builder ()
101- .content (
102- List .of (
103- new McpSchema .TextContent (
104- JsonUtils .pojoToJson (Map .of ("error" , "Unknown function: " + toolName )))))
105- .isError (true )
106- .build ();
119+ return new CallToolOutcome (
120+ McpSchema .CallToolResult .builder ()
121+ .content (
122+ List .of (
123+ new McpSchema .TextContent (
124+ JsonUtils .pojoToJson (
125+ Map .of ("error" , "Unknown function: " + toolName )))))
126+ .isError (true )
127+ .build (),
128+ elapsedMs (startNanos ),
129+ McpToolCallUsage .ErrorCategory .VALIDATION );
107130 }
108131
109- return McpSchema .CallToolResult .builder ()
110- .content (List .of (new McpSchema .TextContent (JsonUtils .pojoToJson (result ))))
111- .isError (false )
112- .build ();
132+ return new CallToolOutcome (
133+ McpSchema .CallToolResult .builder ()
134+ .content (List .of (new McpSchema .TextContent (JsonUtils .pojoToJson (result ))))
135+ .isError (false )
136+ .build (),
137+ elapsedMs (startNanos ),
138+ null );
113139 } catch (AuthorizationException ex ) {
114140 LOG .warn ("Authorization error: {}" , ex .getMessage ());
115- return McpSchema .CallToolResult .builder ()
116- .content (
117- List .of (
118- new McpSchema .TextContent (
119- JsonUtils .pojoToJson (
120- Map .of (
121- "error" ,
122- String .format ("Authorization error: %s" , ex .getMessage ()),
123- "statusCode" ,
124- 403 )))))
125- .isError (true )
126- .build ();
141+ return new CallToolOutcome (
142+ McpSchema .CallToolResult .builder ()
143+ .content (
144+ List .of (
145+ new McpSchema .TextContent (
146+ JsonUtils .pojoToJson (
147+ Map .of (
148+ "error" ,
149+ String .format ("Authorization error: %s" , ex .getMessage ()),
150+ "statusCode" ,
151+ 403 )))))
152+ .isError (true )
153+ .build (),
154+ elapsedMs (startNanos ),
155+ McpToolCallUsage .ErrorCategory .AUTH );
127156 } catch (Exception ex ) {
128157 LOG .error ("Error executing tool '{}': {}" , toolName , ex .getMessage (), ex );
129- return McpSchema .CallToolResult .builder ()
130- .content (
131- List .of (
132- new McpSchema .TextContent (
133- JsonUtils .pojoToJson (
134- Map .of (
135- "error" ,
136- String .format ("Error executing tool: %s" , ex .getMessage ()),
137- "statusCode" ,
138- 500 )))))
139- .isError (true )
140- .build ();
158+ return new CallToolOutcome (
159+ McpSchema .CallToolResult .builder ()
160+ .content (
161+ List .of (
162+ new McpSchema .TextContent (
163+ JsonUtils .pojoToJson (
164+ Map .of (
165+ "error" ,
166+ String .format ("Error executing tool: %s" , ex .getMessage ()),
167+ "statusCode" ,
168+ 500 )))))
169+ .isError (true )
170+ .build (),
171+ elapsedMs (startNanos ),
172+ classifyException (ex ));
141173 }
142174 }
175+
176+ /**
177+ * Maps an arbitrary exception type to one of the {@link McpToolCallUsage.ErrorCategory} values.
178+ * Walks the cause chain because the tool wrappers usually rethrow framework errors wrapped in
179+ * a {@link RuntimeException}. Defaults to {@link McpToolCallUsage.ErrorCategory#INTERNAL} when
180+ * no specific bucket matches.
181+ */
182+ static McpToolCallUsage .ErrorCategory classifyException (Throwable t ) {
183+ McpToolCallUsage .ErrorCategory result = McpToolCallUsage .ErrorCategory .INTERNAL ;
184+ Throwable cursor = t ;
185+ while (cursor != null && result == McpToolCallUsage .ErrorCategory .INTERNAL ) {
186+ McpToolCallUsage .ErrorCategory match = matchCategory (cursor );
187+ if (match != null ) {
188+ result = match ;
189+ } else {
190+ Throwable next = cursor .getCause ();
191+ cursor = (next == null || next == cursor ) ? null : next ;
192+ }
193+ }
194+ return result ;
195+ }
196+
197+ /**
198+ * Pairing of an exception (name, message) predicate with the bucket it should produce. Kept
199+ * as a static table so adding a new category (or extending an existing one with a new keyword)
200+ * is a one-line change rather than another {@code else if} branch.
201+ */
202+ private record CategoryMatcher (
203+ Predicate <ExceptionMeta > matches , McpToolCallUsage .ErrorCategory category ) {}
204+
205+ /** Lower-cased name + message pair so each matcher inspects both without re-parsing. */
206+ private record ExceptionMeta (String name , String message ) {}
207+
208+ /**
209+ * Ordered category table. Check order matters: more specific patterns sit before broader ones so
210+ * a {@code RateLimitException} doesn't get caught by the generic message-substring rules below
211+ * it. {@code AUTH} sits above {@code VALIDATION} because some auth exceptions ({@code
212+ * AuthorizationException}) extend {@code IllegalArgumentException}-style hierarchies and would
213+ * otherwise be mis-bucketed.
214+ */
215+ private static final List <CategoryMatcher > CATEGORY_MATCHERS =
216+ List .of (
217+ new CategoryMatcher (
218+ meta -> meta .name ().contains ("RateLimit" ) || meta .message ().contains ("rate limit" ),
219+ McpToolCallUsage .ErrorCategory .RATE_LIMIT ),
220+ new CategoryMatcher (
221+ meta ->
222+ meta .name ().contains ("Authorization" )
223+ || meta .name ().contains ("Forbidden" )
224+ || meta .name ().contains ("Unauthorized" )
225+ || meta .message ().contains ("forbidden" )
226+ || meta .message ().contains ("unauthorized" )
227+ || meta .message ().contains ("access denied" )
228+ || meta .message ().contains ("permission denied" ),
229+ McpToolCallUsage .ErrorCategory .AUTH ),
230+ new CategoryMatcher (
231+ meta ->
232+ meta .name ().contains ("Validation" )
233+ || meta .name ().contains ("IllegalArgument" )
234+ || meta .name ().contains ("BadRequest" )
235+ || meta .message ().contains ("invalid argument" ),
236+ McpToolCallUsage .ErrorCategory .VALIDATION ),
237+ new CategoryMatcher (
238+ meta ->
239+ meta .name ().contains ("Timeout" )
240+ || meta .message ().contains ("timeout" )
241+ || meta .message ().contains ("timed out" ),
242+ McpToolCallUsage .ErrorCategory .TIMEOUT ));
243+
244+ /**
245+ * Returns the category that matches the supplied throwable's name or message, or {@code null}
246+ * when no specific bucket applies. Kept separate from {@link #classifyException} so the
247+ * cause-chain walk reads as a single linear loop.
248+ */
249+ private static McpToolCallUsage .ErrorCategory matchCategory (Throwable cursor ) {
250+ ExceptionMeta meta =
251+ new ExceptionMeta (
252+ cursor .getClass ().getSimpleName (),
253+ cursor .getMessage () == null ? "" : cursor .getMessage ().toLowerCase (Locale .ROOT ));
254+ return CATEGORY_MATCHERS .stream ()
255+ .filter (matcher -> matcher .matches ().test (meta ))
256+ .map (CategoryMatcher ::category )
257+ .findFirst ()
258+ .orElse (null );
259+ }
260+
261+ private static long elapsedMs (long startNanos ) {
262+ return (System .nanoTime () - startNanos ) / 1_000_000L ;
263+ }
264+
265+ /**
266+ * Phase 3 — tuple returned by {@link #callToolWithMetadata} so the MCP server can record the
267+ * call with full diagnostic detail without re-classifying the exception or re-measuring the
268+ * latency at its level.
269+ */
270+ public record CallToolOutcome (
271+ McpSchema .CallToolResult result ,
272+ long latencyMs ,
273+ McpToolCallUsage .ErrorCategory errorCategory ) {}
143274}
0 commit comments