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

  1. kliushnichenko commented on Dec 18, 2025

    @kliushnichenko
    Contributor

    not sure what you are going to implement here, but just wanted to share the jooby-mcp module I've been working on lately, maybe some of the jooby folks will find it useful.

  2. jknack commented on Dec 18, 2025

    @jknack
    MemberAuthor

    I did a quick look at what you did. It looks good and help me to understand a bit more mcp in general. What I would like to do is to integrate open-api with mcp, which means:

    • you generate an open-api output from you app. The app must be documented properly with javadoc or open-api annotations.
    • convert each open-api operation into a fully described tool (operationId, params, schemas)
    • creates a mcp server transport on top of jooby

    That was all in my mind so far, which is basically: tool

  3. jknack commented on Dec 19, 2025

    @jknack
    MemberAuthor
  4. kliushnichenko commented on Dec 20, 2025

    @kliushnichenko
    Contributor

    mcp-inspector is super helpful if you want to play with the server through the UI.
    SDK Client - if you are looking for a programmatic API

  5. jknack commented on Dec 20, 2025

    @jknack
    MemberAuthor

    I tried the mcp-inspector but required some cors setup.

  6. kliushnichenko commented on Dec 20, 2025

    @kliushnichenko
    Contributor

    You probably tried a Direct connection. I typically run Via Proxy

    Image
  7. jknack commented on Dec 20, 2025

    @jknack
    MemberAuthor

    that was it, thank you.

  8. kliushnichenko commented on Dec 20, 2025

    @kliushnichenko
    Contributor

    just shipped McpInspectorModule for Jooby that lets you test the mcp-server alongside

  9. jknack commented on Dec 21, 2025

    @jknack
    MemberAuthor

    @kliushnichenko why the stateless transport isn't supported?

  10. kliushnichenko commented on Dec 21, 2025

    @kliushnichenko
    Contributor

    Unfortunately, mcp-java-sdk doesn’t provide a common abstraction (specification) for stateful and stateless models, so supporting this would require some boilerplate code on our side.
    It’s not a problem, but I’ve decided to postpone it until better times or until someone explicitly requests it.
    Are you interested in it? 🙂

  11. jknack commented on Dec 21, 2025

    @jknack
    MemberAuthor

    probably, just learned the sse is deprecated the way to go is with streamable-http. I'm going to send a PR today, think. That will includes a new open-api output format: mcp.json:

    {
       tools: [{
         {
      "name": "get_forecast",
      "description": "Get a 5-day weather forecast for a city.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "city": {
            "type": "string",
            "description": "The name of the city (e.g., London, Tokyo)"
          },
          "days": {
            "type": "number",
            "minimum": 1,
            "maximum": 5,
            "default": 3
          }
        },
        "required": ["city"]
      }
    }
       }],
    resources: [{
    {
      "uriTemplate": "archive://{year}/{month}",
      "name": "Monthly Archives",
      "description": "Access historical reports by year and month",
      "parameters": {
        "year": { "type": "string" },
        "month": { "type": "string" }
      }
    }
     }]
    }

    Will be just that for now and will proceed to release 4.1.0.

    My idea is that the jooby-mcp read this and boot the mcp server.

  12. jknack commented on Mar 23, 2026

    @jknack
    MemberAuthor

    @kliushnichenko I'm planning to add your module with few changes I would like to discuss with you:

    • all the work you did for transport/server/runner will go as it is
    • Want to remove the middle-men classes mainly all the *Spec classes
    • The generated code will have a callback method where it is going to register all the tools, prompts, etc.. using the MCP API
    • The generated code won't be exactly the same you generated it will follow what was released already as part of the *RPC effort
    • The generated code will live in the jooby APT project (no new project/module)
    • Probably will remove your schema generator tool. Why? The mcp-sdk has a very strong dependency on Jackson. Jackson has multiple ways to affect serialization/deserialization which may produce a mismatch later. Also, your effort for building a compile-time is good and is the way to go due to the mcp-sdk limitation it lost his goal of being a compile-time generator, we must output json-schema-string which requires to be parsed once at boot time by Jackson. Think https://github.com/victools/jsonschema-generator will do it.
    • all annotations will go into io.jooby.annotation (core)
    • all annotations will be prefixed with Mcp
    • there will be a single annotation for resource/resourcetemplates @McpResource
    • for not, it will be only Jackson 3 (don't think it is worth it to add Jackson 2, but we can see it later)

    Thoughts?

  13. changed the title [-]feature: add mcp server on top of openapi[/-] [+]feature: add mcp support[/+] on Mar 23, 2026
  14. modified the milestone: 4.1.1 on Mar 23, 2026
  15. kliushnichenko commented on Mar 23, 2026

    @kliushnichenko
    Contributor

    Glad you found something useful in my project 😊
    A few comments below

    Want to remove the middle-men classes mainly all the *Spec classes

    should be only one - ToolSpec. Was added solely to track the required arguments, to be able to validate at the tool call phase

    The generated code will live in the jooby APT project (no new project/module)

    I wasn’t sure (and still am not) that putting all this MCP-related code into jooby-apt is the right approach. Users who are not interested in MCP will still end up pulling it in, along with its transitive dependencies.
    We probably care less about build-time, but it still doesn't feel quite right.
    This is one of the reasons why I initially started it as a separate project.

    for not, it will be only Jackson 3 (don't think it is worth it to add Jackson 2, but we can see it later)

    I recommend leaving a room for Jackson2, at least an extension point for it.

    Please, also keep multi-server support.

  16. jknack commented on Mar 23, 2026

    @jknack
    MemberAuthor

    should be only one - ToolSpec. Was added solely to track the required arguments, to be able to validate at the tool call phase

    After I wrote realize this was the only *Spec.

    I wasn’t sure (and still am not) that putting all this MCP-related code into jooby-apt is the right approach. Users who are not interested in MCP will still end up pulling it in, along with its transitive dependencies.
    We probably care less about build-time, but it still doesn't feel quite right.
    This is one of the reasons why I initially started it as a separate project.

    The way I see it existing clients won't be affected by this (except for a jar with few more bytes). The tool just write code, no dependency is carry-on for final user until they write mcp, rpc, code.

    Please, also keep multi-server support.

    Yes, I will. Except we won't force a user to setup the default server and when no server is defined we will provide a default one.

  17. added this to the 4.1.1 milestone on Mar 30, 2026
  18. jknack commented on Mar 30, 2026

    @jknack
    MemberAuthor

    completed #3886

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