Skip to content

Writing Tools

Tools are the primary way to expose functionality to MCP clients. A tool is any Java method annotated with @McpTool on a Spring bean.

Defining a Tool

Annotate methods with @McpTool and register the enclosing class as a Spring bean:

import com.callibrity.mocapi.api.tools.McpTool;
import org.springframework.stereotype.Component;

@Component
public class WeatherTool {

    @McpTool(name = "get-weather", description = "Gets the current weather for a city")
    public WeatherResponse getWeather(String city) {
        // your logic here
        return new WeatherResponse(city, 72.0, "sunny");
    }

    public record WeatherResponse(String city, double temperature, String condition) {}
}

Any bean-hood mechanism works — @Component, @Service, or a @Bean factory method:

@Configuration
public class ToolConfig {

    @Bean
    public WeatherTool weatherTool(WeatherApiClient client) {
        return new WeatherTool(client);
    }
}

The framework discovers every bean in the context, scans its methods for @McpTool, and registers one handler per annotated method. Each registered tool is logged at INFO level during startup (see Startup Logging for the full catalog).

Tool Method Basics

A @McpTool method receives its arguments as method parameters and returns a result. The framework handles JSON serialization in both directions.

@McpTool(name = "add", description = "Adds two numbers")
public AddResult add(int a, int b) {
    return new AddResult(a + b);
}

public record AddResult(int sum) {}

Tool return types are strictly checked at registration time — the signature determines exactly how mocapi maps the value to a CallToolResult, with no runtime guessing. See Permitted Return Types for the full rule set. Tools whose return type doesn't match one of the permitted shapes fail to register with a clear message at startup.

Naming

If you omit the name attribute, the framework generates a name from the class and method names. For a class MathTool with method add, the generated name would be math-tool.add.

You can also set a title and description:

@McpTool(
    name = "calculate",
    title = "Calculator",
    description = "Performs basic arithmetic operations")
public CalculatorResult calculate(String operation, double a, double b) { ... }

public record CalculatorResult(double value) {}

Parameters

Method parameters map directly to the tool's input schema. The framework generates a JSON Schema from the method signature:

@McpTool(name = "search", description = "Searches for documents")
public SearchResult search(String query, int maxResults) { ... }

This generates an input schema like:

{
  "type": "object",
  "properties": {
    "query": { "type": "string" },
    "maxResults": { "type": "integer" }
  },
  "required": ["query", "maxResults"]
}

Use Swagger/OpenAPI annotations for richer schemas:

import io.swagger.v3.oas.annotations.media.Schema;

@McpTool(name = "search", description = "Searches for documents")
public SearchResult search(
    @Schema(description = "The search query") String query,
    @Schema(description = "Maximum results to return", minimum = "1", maximum = "100") int maxResults) {
    ...
}

Record Parameters

For tools with many parameters, use a record annotated with @McpToolParams:

import com.callibrity.mocapi.api.tools.McpToolParams;

@McpTool(name = "create-user", description = "Creates a new user")
public UserResponse createUser(@McpToolParams CreateUserRequest request) {
    return new UserResponse(request.name(), request.email());
}

public record CreateUserRequest(String name, String email, int age) {}

The input schema is generated from the record's fields.

Permitted Return Types

The signature determines exactly how mocapi maps the value to a CallToolResult. The rule is applied to the effective return type — if the method returns CompletionStage<X> (or CompletableFuture<X>), mocapi unwraps one layer and applies the rules to X.

Shape Behavior outputSchema
void / Void Empty CallToolResult (text-only, no structured content)
CallToolResult Author constructs the result manually; passed through as-is
CharSequence (typically String) toString() becomes a single text content block; no structured content
A single ContentBlock (ImageContent, AudioContent, ResourceLink, EmbeddedResource, TextContent) Wrapped as the sole item of the result's content list; no structured content
Any other type Jackson-serialized to structuredContent with a matching text block Advertised when a concrete schema type is derivable

Since MCP 2026-07-28, structuredContent may be any JSON value, so the last row covers records/POJOs and collections, arrays, maps, and scalars:

You return structuredContent outputSchema
Record/POJO JSON object type: "object" with declared properties
List<Widget> / Widget[] JSON array type: "array"
Map<String, Widget> JSON object (open shape) type: "object"
int / double / boolean JSON number/boolean type: "integer" / "number" / "boolean"
JsonNode / ObjectNode the node as-is type: "object"
raw Object serialized value none advertised (no derivable type)

Async is supported by wrapping any of the above in CompletionStage<T> / CompletableFuture<T>, nested to any depth (CompletionStage<CompletableFuture<X>> and deeper). Mocapi peels every async layer down to the effective inner type and applies its mapping; a single awaiting interceptor loops at runtime to resolve all layers. Each stage must carry a concrete type argument.

Still rejected at registration — the cases where mocapi genuinely can't derive a mapping:

You wrote Why it's rejected Fix
Optional<Widget> Element type is erased on the return signature — no schema derivable Return the value directly (or null), or CallToolResult
Raw CompletionStage / wildcard CompletionStage<?> / unresolved type variable No concrete type argument to unwrap Parameterize the stage with a concrete type

Structured Return (the common case)

Return a record or POJO. The framework serializes it to JSON, puts it in structuredContent, and also produces a text representation in content.

// Returns {"message": "Hello!"} as structuredContent
@McpTool(name = "greet", description = "Greets someone")
public GreetResponse greet(String name) {
    return new GreetResponse("Hello, " + name + "!");
}

public record GreetResponse(String message) {}

Text-Only Return

A tool that just wants to return a line of prose can return String (or any CharSequence). No structured content is produced and no output schema is advertised.

@McpTool(name = "motd", description = "Returns the message of the day")
public String motd() {
    return "Be excellent to each other.";
}

Single Content Block Return

A tool that wants to return one non-text content item — an image, audio clip, resource link, or embedded resource — can return the ContentBlock directly instead of hand-building a CallToolResult. The block becomes the sole item of the result's content; no structured content is produced.

@McpTool(name = "chart", description = "Renders a chart as a PNG")
public ImageContent chart(String series) {
    return new ImageContent(renderPngBase64(series), "image/png", null);
}

For multiple content blocks, return a CallToolResult and assemble the list yourself.

Void Tools

A tool that returns void (or Void) produces an empty CallToolResult. Useful for fire-and-forget operations with no meaningful return value.

@McpTool(name = "notify", description = "Sends a notification")
public void sendNotification(String message) {
    notificationService.send(message);
}

Returning CallToolResult Directly

For full control over the response — multiple content blocks, custom isError, or a structured payload whose shape mocapi can't (or shouldn't) derive — return a CallToolResult. No output schema is advertised in this case; the author owns the full shape of the result.

import com.callibrity.mocapi.model.CallToolResult;
import com.callibrity.mocapi.model.TextContent;

@McpTool(name = "status", description = "Returns system status")
public CallToolResult getStatus() {
    return new CallToolResult(
        List.of(new TextContent("System is healthy", null)),
        null,
        null);
}

Async Tools

A tool that does I/O or delegates to an async API can return CompletionStage<T> / CompletableFuture<T>. Mocapi awaits the future on the server thread and maps the awaited value using the same rules as for a synchronous return of T.

@McpTool(name = "fetch-weather", description = "Fetches current weather asynchronously")
public CompletableFuture<WeatherResponse> fetchWeather(String city) {
    return weatherClient.fetchAsync(city)
        .thenApply(payload -> new WeatherResponse(city, payload.tempF(), payload.condition()));
}

public record WeatherResponse(String city, double temperature, String condition) {}

If the future completes exceptionally, the original exception surfaces with its type preserved (the JDK's CompletionException wrapper is unwrapped). Domain exceptions therefore flow through the same error-handling path as synchronously thrown ones.

Custom Parameter Resolvers

The tool builder wires mocapi's own parameter resolvers by default — an McpToolContext resolver, an @McpToolParams deserializer, and a Jackson catch-all that deserializes named arguments from the request tree. You can layer your own resolver alongside those to bind bespoke parameter types (for example, a "current tenant" pulled from the session).

Write the resolver:

public final class CurrentTenantResolver implements ParameterResolver<JsonNode> {
    @Override
    public boolean supports(ParameterInfo info) {
        return info.parameter().isAnnotationPresent(CurrentTenant.class)
                && info.resolvedType() == String.class;
    }

    @Override
    public Object resolve(ParameterInfo info, JsonNode arguments) {
        var jwt = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        return jwt.getToken().getClaimAsString("tenant");
    }
}

Attach it to every tool via a customizer bean:

@Bean
CallToolHandlerCustomizer currentTenantResolverCustomizer() {
    CurrentTenantResolver resolver = new CurrentTenantResolver();
    return config -> config.resolver(resolver);
}

And declare it on a handler:

@McpTool(name = "list_tenant_widgets")
public TenantWidgets listTenantWidgets(@CurrentTenant String tenant) {
    return new TenantWidgets(widgetService.listForTenant(tenant));
}

public record TenantWidgets(List<Widget> widgets) {}

A bare List<Widget> would be rejected at registration — MCP requires structuredContent to be a JSON object, not an array, so the payload is wrapped in a record.

Ordering: user resolvers are placed ahead of the catch-all Jackson resolver, so a specific supports() check always wins over generic JSON deserialization. Resolver selection within Methodical is first-match-wins.

Externalizing Metadata

Every string attribute on @McpTool (name, title, description) supports Spring's ${...} property placeholder syntax, so long descriptions don't have to live inline on the annotation. See Externalizing Annotation Metadata.

Error Handling

Automatic Error Wrapping

Any exception thrown from a tool method is caught by the framework and returned as a CallToolResult with isError=true. The exception message is sent to the LLM as text content:

@McpTool(name = "divide", description = "Divides two numbers")
public DivideResult divide(double a, double b) {
    if (b == 0) {
        throw new IllegalArgumentException("Cannot divide by zero");
    }
    return new DivideResult(a / b);
}

public record DivideResult(double quotient) {}

If b is 0, the client receives:

{
  "content": [{"type": "text", "text": "Cannot divide by zero"}],
  "isError": true
}

This follows the MCP specification's distinction: - Tool errors (exceptions from your code) become CallToolResult with isError=true -- the LLM can see and react to the error - Protocol errors (unknown tool, invalid parameters) become JSON-RPC errors -- these indicate a problem with the request itself

Rich Error Results with McpToolException

Plain exceptions only give you a text message. For tool errors that should carry structured machine-readable detail — error categories, domain-specific codes, or extra content blocks — throw McpToolException (from mocapi-api). Mocapi catches it and produces a CallToolResult with isError: true whose structuredContent is your serialized error payload and whose content starts with the exception message.

import com.callibrity.mocapi.api.tools.McpToolException;

@McpTool(name = "get-user", description = "Looks up a user by id")
public UserResponse getUser(String id) {
    return userRepository.findById(id)
        .map(UserResponse::from)
        .orElseThrow(() -> new McpToolException(
            "User not found: " + id,
            new UserNotFoundDetails("USER_NOT_FOUND", id)));
}

public record UserNotFoundDetails(String code, String userId) {}

The client receives:

{
  "content": [{"type": "text", "text": "User not found: 42"}],
  "isError": true,
  "structuredContent": { "code": "USER_NOT_FOUND", "userId": "42" }
}

Note on error codes. MCP has no dedicated "error code" slot on CallToolResult — the only machine-readable error signal per spec is the isError flag. If you want to communicate a code or category to the client, put it inside structuredContent, as in the example above.

Structured content must serialize to a JSON object. Mocapi validates this at catch time: passing a String, List, number, or any non-object payload fails the request with a diagnostic error message rather than silently dropping the structuredContent field. Wrap your error data in a record or POJO.

Subclassing for reusable error shapes

Define domain-specific subclasses for errors you throw from more than one place. Mocapi catches the parent type, so every subclass is handled uniformly.

public class UserNotFoundException extends McpToolException {
    private final String userId;

    public UserNotFoundException(String userId) {
        super("User not found: " + userId);
        this.userId = userId;
    }

    @Override
    public Object getStructuredContent() {
        return new Details("USER_NOT_FOUND", userId);
    }

    public record Details(String code, String userId) {}
}

Now any tool can just throw new UserNotFoundException(id) and get the same structured error shape on the wire.

Appending extra content blocks

Override getAdditionalContent() to append extra content blocks (images, embedded resources, extra text) after the automatically-generated message block:

throw new McpToolException("Validation failed") {
    @Override
    public List<ContentBlock> getAdditionalContent() {
        return List.of(new TextContent("See https://example.com/docs/validation", null));
    }
};

Async

Works the same way inside a CompletionStage / CompletableFuture that completes exceptionally — the await interceptor unwraps the JDK's CompletionException so the original McpToolException reaches the error path unchanged.

@McpTool(name = "get-user-async", description = "Async user lookup")
public CompletableFuture<UserResponse> getUserAsync(String id) {
    return userClient.fetchAsync(id).thenApply(maybe -> maybe.orElseThrow(() ->
        new UserNotFoundException(id)));
}

Explicit Error Results

You can also return an error result explicitly:

@McpTool(name = "validate", description = "Validates input")
public CallToolResult validate(String input) {
    if (input.isBlank()) {
        return new CallToolResult(
            List.of(new TextContent("Input must not be blank", null)),
            true,
            null);
    }
    return new CallToolResult(
        List.of(new TextContent("Valid", null)),
        null,
        null);
}