spring-projects/spring-ai · error · UndeclaredThrowableException

UndeclaredThrowableException

Error message

UndeclaredThrowableException

What it means

AbstractMcpToolMethodCallback.callMethod rethrows the checked exception raised inside a tool method as UndeclaredThrowableException when it is neither a RuntimeException nor an Error. Because the callback's invoke path does not declare checked exceptions, any checked exception thrown by the user's tool method is wrapped; the real cause is available via getCause().

Source

Thrown at mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java:90

	 * @throws RuntimeException if there's an error invoking the method
	 */
	protected Object callMethod(Object[] methodArguments) {
		this.toolMethod.setAccessible(true);
		try {
			return this.toolMethod.invoke(this.toolObject, methodArguments);
		}
		catch (IllegalAccessException ex) {
			throw new RuntimeException("Failed to access tool method", ex);
		}
		catch (InvocationTargetException ex) {
			Throwable cause = ex.getCause();
			if (cause instanceof RuntimeException re) {
				throw re;
			}
			if (cause instanceof Error err) {
				throw err;
			}
			throw new UndeclaredThrowableException(cause);
		}
	}

	/**
	 * Builds the method arguments from the context, tool input arguments, and optionally
	 * the full request.
	 * @param exchangeOrContext The exchange or context object (e.g.,
	 * McpSyncServerExchange, McpAsyncServerExchange, or McpTransportContext)
	 * @param toolInputArguments The input arguments from the tool request
	 * @param request The full CallToolRequest (optional, can be null)
	 * @return An array of method arguments
	 */
	protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments,
			CallToolRequest request) {

		return Stream.of(this.toolMethod.getParameters()).map(parameter -> {

			if (McpSyncRequestContext.class.isAssignableFrom(parameter.getType())

View on GitHub (pinned to 98a7beda4f)

Solutions

  1. Unwrap with exception.getCause() in the calling error handler to see the real checked exception
  2. Change the tool method to wrap checked exceptions in a RuntimeException (or a custom unchecked type)
  3. Catch the checked exception inside the tool method and return an error CallToolResult instead of throwing
  4. Catch UndeclaredThrowableException at the dispatch boundary and inspect its cause

Example fix

// before
String readFile(String p) throws IOException { return Files.readString(Path.of(p)); }
// after
String readFile(String p) {
    try { return Files.readString(Path.of(p)); }
    catch (IOException e) { throw new UncheckedIOException(e); }
}
Defensive patterns

Strategy: try-catch

Try / catch

try {
    result = callback.call(request);
} catch (UndeclaredThrowableException e) {
    Throwable real = e.getCause();
    log.error("Tool method threw checked exception", real);
    return errorCallToolResult(real);
}

Prevention

When it happens

Trigger: A tool method declares and throws a checked exception (e.g. IOException, SQLException) and the underlying transport call hits that path.

Common situations: Tool methods calling file/DB/network APIs with checked exceptions; proxy-based invocation (proxies only propagate unchecked exceptions) making the wrap mandatory.

Related errors


AI-assisted analysis of spring-projects/spring-ai@98a7beda4f (2026-09-11). Data as JSON: /api/errors/aa9570a3deaac49f. Report an issue: GitHub.