Skip to content

feature: add mcp support #3830

Description

@jknack

Description

This commit introduces first-class support for the Model Context Protocol (MCP) in Jooby, built natively on top of the official MCP Java SDK. It provides a highly efficient, annotation-driven architecture for exposing server capabilities to LLM clients.

Key Features & Architecture:

  • Annotation-Driven: Introduces a new suite of annotations in the io.jooby.annotation.mcp namespace to define server capabilities.
  • Smart Schema Documentation: The APT processor automatically extracts rich descriptions for tools and parameters by intelligently falling back from explicit annotation attributes (e.g., @McpTool(description="...")) to standard Javadoc comments, ensuring LLMs get the context they need without forcing developers to duplicate documentation.
  • Zero-Overhead Generation: The APT processor automatically generates a specialized *Mcp_ routing class that implements io.jooby.mcp.McpService, ensuring fast startup without runtime reflection.
  • Flexible JSON Ecosystems: The core McpModule is completely decoupled from JSON serialization. Developers must explicitly register either McpJackson2Module (for Jackson 2) or McpJackson3Module (for Jackson 3) alongside their respective Jooby Jackson modules.
  • Comprehensive Transports: Fully supports all major MCP communication protocols, including Stateless, Streamable HTTP, Server-Sent Events (SSE), and WebSockets.
  • Multi-Server Routing: Developers can configure and expose multiple distinct MCP servers within the same application using the @McpServer annotation.
  • Developer Tooling: Includes an out-of-the-box McpInspectorModule to facilitate easy testing and debugging of MCP endpoints during development.

Example

Input:

import java.util.List;
import java.util.Optional;

import io.jooby.annotation.mcp.McpCompletion;
import io.jooby.annotation.mcp.McpPrompt;
import io.jooby.annotation.mcp.McpResource;
import io.jooby.annotation.mcp.McpTool;
import io.modelcontextprotocol.server.McpSyncServerExchange;

/** A collection of tools, prompts, and resources exposed to the LLM via MCP. */
public class CalculatorTools {

  /**
   * Adds two integers together and returns the result.
   *
   * @param a The first number to add.
   * @param b The second number to add.
   * @return The sum of the two numbers.
   */
  @McpTool(name = "add_numbers")
  public int add(int a, int b) {
    return a + b;
  }

  @McpPrompt(name = "math_tutor", description = "A prompt to initiate a math tutoring session")
  public String mathTutor(String topic) {
    return "You are a helpful math tutor. Please explain the concept of "
        + topic
        + " step by step.";
  }

  @McpResource(
      uri = "calculator://manual/usage",
      name = "Calculator Manual",
      description = "Instructions on how to use the calculator")
  public String manual() {
    return "The Calculator supports basic arithmetic. Use the add_numbers tool to sum values.";
  }

  @McpResource(
      uri = "calculator://history/{user}",
      name = "User History",
      description = "Retrieves the calculation history for a specific user")
  public String history(String user) {
    return "History for " + user + ":\n5 + 10 = 15\n2 * 4 = 8";
  }

  @McpCompletion(ref = "calculator://history/{user}")
  public List<String> historyCompletions(String user) {
    // In a real app, this would query a database for active usernames matching the input
    return List.of("alice", "bob", "charlie");
  }

  @McpTool(
      name = "get_session_info",
      description = "Returns the current MCP session ID using the injected exchange.")
  public String getSessionInfo(McpSyncServerExchange exchange) {
    return Optional.ofNullable(exchange.sessionId()).orElse("No active session");
  }
}

Output:

@io.jooby.annotation.Generated(CalculatorTools.class)
public class CalculatorToolsMcp_ implements io.jooby.mcp.McpService {
    protected java.util.function.Function<io.jooby.Context, CalculatorTools> factory;

    public CalculatorToolsMcp_() {
      this(io.jooby.SneakyThrows.singleton(CalculatorTools::new));
    }

    public CalculatorToolsMcp_(CalculatorTools instance) {
      setup(ctx -> instance);
    }

    public CalculatorToolsMcp_(io.jooby.SneakyThrows.Supplier<CalculatorTools> provider) {
      setup(ctx -> provider.get());
    }

    public CalculatorToolsMcp_(io.jooby.SneakyThrows.Function<Class<CalculatorTools>, CalculatorTools> provider) {
      setup(ctx -> provider.apply(CalculatorTools.class));
    }

    private void setup(java.util.function.Function<io.jooby.Context, CalculatorTools> factory) {
      this.factory = factory;
    }

    private io.modelcontextprotocol.json.McpJsonMapper json;
    @Override
    public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) {
      capabilities.tools(true);
      capabilities.prompts(true);
      capabilities.resources(true, true);
      capabilities.completions();
    }

    @Override
    public String serverKey() {
      return "default";
    }

    @Override
    public java.util.List<io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification> completions() {
      var completions = new java.util.ArrayList<io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification>();
      completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("math_tutor"), (exchange, req) -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())));
      completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("calculator://history/{user}"), (exchange, req) -> this.historyCompletionHandler(exchange, null, req)));
      return completions;
    }

    @Override
    public java.util.List<io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification> statelessCompletions() {
      var completions = new java.util.ArrayList<io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification>();
      completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("math_tutor"), (ctx, req) -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())));
      completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("calculator://history/{user}"), (ctx, req) -> this.historyCompletionHandler(null, ctx, req)));
      return completions;
    }

    @Override
    public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception {
      this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class);
      var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class);

      server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, null, req)));
      server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(mathTutorPromptSpec(), (exchange, req) -> this.mathTutor(exchange, null, req)));
      server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(manualResourceSpec(), (exchange, req) -> this.manual(exchange, null, req)));
      server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(historyResourceTemplateSpec(), (exchange, req) -> this.history(exchange, null, req)));
      server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(getSessionInfoToolSpec(schemaGenerator), (exchange, req) -> this.getSessionInfo(exchange, null, req)));
    }

    @Override
    public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception {
      this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class);
      var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class);

      server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> this.add(null, ctx, req)));
      server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(mathTutorPromptSpec(), (ctx, req) -> this.mathTutor(null, ctx, req)));
      server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(manualResourceSpec(), (ctx, req) -> this.manual(null, ctx, req)));
      server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(historyResourceTemplateSpec(), (ctx, req) -> this.history(null, ctx, req)));
      server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(getSessionInfoToolSpec(schemaGenerator), (ctx, req) -> this.getSessionInfo(null, ctx, req)));
    }

    private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) {
      var schema = new java.util.LinkedHashMap<String, Object>();
      schema.put("type", "object");
      var props = new java.util.LinkedHashMap<String, Object>();
      schema.put("properties", props);
      var req = new java.util.ArrayList<String>();
      schema.put("required", req);
      var schema_a = schemaGenerator.generateSchema(int.class);
      props.put("a", schema_a);
      req.add("a");
      var schema_b = schemaGenerator.generateSchema(int.class);
      props.put("b", schema_b);
      req.add("b");
      return new io.modelcontextprotocol.spec.McpSchema.Tool("add_numbers", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null);
    }

    private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var args = req.arguments() != null ? req.arguments() : java.util.Collections.<String, Object>emptyMap();
      var c = this.factory.apply(ctx);
      var raw_a = args.get("a");
      if (raw_a == null) throw new IllegalArgumentException("Missing req param: a");
      var a = ((Number) raw_a).intValue();
      var raw_b = args.get("b");
      if (raw_b == null) throw new IllegalArgumentException("Missing req param: b");
      var b = ((Number) raw_b).intValue();
      var result = c.add(a, b);
      return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false);
    }

    private io.modelcontextprotocol.spec.McpSchema.Prompt mathTutorPromptSpec() {
      var args = new java.util.ArrayList<io.modelcontextprotocol.spec.McpSchema.PromptArgument>();
      args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("topic", null, false));
      return new io.modelcontextprotocol.spec.McpSchema.Prompt("math_tutor", null, "A prompt to initiate a math tutoring session", args);
    }

    private io.modelcontextprotocol.spec.McpSchema.GetPromptResult mathTutor(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var args = req.arguments() != null ? req.arguments() : java.util.Collections.<String, Object>emptyMap();
      var c = this.factory.apply(ctx);
      var raw_topic = args.get("topic");
      var topic = raw_topic != null ? raw_topic.toString() : null;
      var result = c.mathTutor(topic);
      return new io.jooby.mcp.McpResult(this.json).toPromptResult(result);
    }

    private io.modelcontextprotocol.spec.McpSchema.Resource manualResourceSpec() {
      return new io.modelcontextprotocol.spec.McpSchema.Resource("calculator://manual/usage", "Calculator Manual", null, "Instructions on how to use the calculator", io.jooby.MediaType.byFileExtension("calculator://manual/usage", "text/plain").getValue(), null, null, null);
    }

    private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult manual(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var args = java.util.Collections.<String, Object>emptyMap();
      var c = this.factory.apply(ctx);
      var result = c.manual();
      return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result);
    }

    private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate historyResourceTemplateSpec() {
      return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("calculator://history/{user}", "User History", null, "Retrieves the calculation history for a specific user", io.jooby.MediaType.byFileExtension("calculator://history/{user}", "text/plain").getValue(), null, null);
    }

    private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult history(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var uri = req.uri();
      var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("calculator://history/{user}");
      var args = new java.util.HashMap<String, Object>();
      args.putAll(manager.extractVariableValues(uri));
      var c = this.factory.apply(ctx);
      var raw_user = args.get("user");
      var user = raw_user != null ? raw_user.toString() : null;
      var result = c.history(user);
      return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result);
    }

    private io.modelcontextprotocol.spec.McpSchema.Tool getSessionInfoToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) {
      var schema = new java.util.LinkedHashMap<String, Object>();
      schema.put("type", "object");
      var props = new java.util.LinkedHashMap<String, Object>();
      schema.put("properties", props);
      var req = new java.util.ArrayList<String>();
      schema.put("required", req);
      return new io.modelcontextprotocol.spec.McpSchema.Tool("get_session_info", null, "Returns the current MCP session ID using the injected exchange.", this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null);
    }

    private io.modelcontextprotocol.spec.McpSchema.CallToolResult getSessionInfo(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var args = req.arguments() != null ? req.arguments() : java.util.Collections.<String, Object>emptyMap();
      var c = this.factory.apply(ctx);
      var result = c.getSessionInfo(exchange);
      return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false);
    }

    private io.modelcontextprotocol.spec.McpSchema.CompleteResult historyCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {
      var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null);
      var c = this.factory.apply(ctx);
      var targetArg = req.argument() != null ? req.argument().name() : "";
      var typedValue = req.argument() != null ? req.argument().value() : "";
      return switch (targetArg) {
        case "user" -> {
          var result = c.historyCompletions(typedValue);
          yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result);
        }
        default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of());
      };
    }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions