001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.examples.mcp; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022 023import org.apache.juneau.bean.mcp.v20260728.*; 024import org.apache.juneau.commons.inject.*; 025import org.apache.juneau.marshall.json.*; 026import org.apache.juneau.marshall.sse.*; 027import org.apache.juneau.rest.server.*; 028import org.apache.juneau.rest.server.mcp.*; 029import org.apache.juneau.rest.server.mcp.v20260728.*; 030 031/** 032 * A complete, runnable MCP <c>2026-07-28</c> server exposing every major surface of the protocol 033 * over one tiny, coherent scenario: an in-memory {@link NoteStore notes} service. 034 * 035 * <p> 036 * This is the reference implementation to copy. Everything the server exposes is registered in 037 * {@link #createMcpConfig()} below, and every handler is a small anonymous class that closes over a 038 * single shared {@link NoteStore}. What each surface demonstrates: 039 * 040 * <ul> 041 * <li><b>tool</b> — {@code publishNote(title, body)} stores a note and then <i>pushes</i> a 042 * {@code resources/updated} change to any subscribers (see the subscription bullet). 043 * <li><b>tool + elicitation / MRTR</b> — {@code deleteNote(title)} returns an 044 * {@code input_required} pause asking the caller to confirm, then resumes to actually delete. 045 * This is one of the framework's two headline features. 046 * <li><b>prompt + completion</b> — {@code summarize} renders a prompt message from a stored 047 * note; its {@code title} argument offers {@code completion/complete} suggestions from existing 048 * note titles. 049 * <li><b>resource</b> — the fixed {@code note:///index} resource lists all note titles. 050 * <li><b>resource template + completion</b> — {@code note:///{title}} reads one note by title; 051 * its {@code title} variable also offers completions. 052 * <li><b>subscription</b> — {@code subscriptions/listen} (SEP-2575 held-open SSE) delivers the 053 * {@code resources/updated} change {@code publishNote} fires. The other headline feature. 054 * </ul> 055 * 056 * <p> 057 * The two overridable factory hooks are the whole story: 058 * <ul> 059 * <li>{@link #createMcpConfig()} — <i>what</i> the server exposes (tools/prompts/resources). 060 * <li>{@link #createMcpOptions()} — <i>how</i> it behaves (advertised capabilities, etc.). 061 * </ul> 062 * 063 * <p> 064 * The {@link Rest @Rest} annotation must register {@link SseSerializer} (alongside the JSON 065 * serializer) so a real servlet container can satisfy the {@code Accept: text/event-stream} 066 * negotiation the subscription stream requires; the base {@link McpRestServlet} wires only JSON. 067 * 068 * @serial exclude 069 */ 070@Rest(serializers = {JsonSerializer.class, SseSerializer.class}, parsers = JsonParser.class, defaultAccept = "application/json") 071@SuppressWarnings({ 072 "java:S110" // Inheritance depth is inherent to extending the Juneau REST server hierarchy. 073}) 074public class ExampleMcpServer extends McpRestServlet { 075 076 private static final long serialVersionUID = 1L; 077 078 /** The {@code title} argument/variable name shared by the tools, prompt, and resource template below. */ 079 private static final String TITLE_ARG = "title"; 080 081 /** The MIME type of every note resource this server serves. */ 082 private static final String TEXT_PLAIN = "text/plain"; 083 084 /** Prefix of the {@code deleteNote} MRTR continuation token, encoding the note title being deleted. */ 085 private static final String DELETE_CONTINUATION_PREFIX = "delete:"; 086 087 // The entire domain state. Handlers below close over this instance, so there is no DI ceremony to 088 // follow: the server object owns its notes, and every MCP surface is a thin view over them. 089 @SuppressWarnings({ 090 "java:S2065" // Servlets are Serializable; transient keeps this non-serializable state out of servlet serialization (java:S1948). 091 }) 092 private final transient NoteStore notes = new NoteStore(); 093 094 /** 095 * Declares everything this server exposes. This one method is the "table of contents" for the demo. 096 */ 097 @Override 098 protected McpServerConfig createMcpConfig() { 099 return new McpServerConfig() 100 .setName("juneau-notes-example") 101 .setVersion("1.0.0") 102 .addTool(publishNoteTool(notes)) 103 .addTool(deleteNoteTool(notes)) 104 .addPrompt(summarizePrompt(notes)) 105 .addResource(noteIndexResource(notes)) 106 .addResourceTemplate(noteTemplate(notes)); 107 } 108 109 /** 110 * Advertises capabilities and instructions. Explicit here so the {@code server/discover} handshake 111 * truthfully reports everything this server supports — notably {@code resources.subscribe}, 112 * which pairs with the {@code subscriptions/listen} demo. 113 * 114 * <p> 115 * On revision {@code 2026-07-28}, {@code server/discover}'s {@code instructions} field is sourced from 116 * this {@link McpOptions}, <b>not</b> {@link McpServerConfig#setInstructions}, which only feeds the 117 * legacy v1 {@code initialize} handshake. 118 */ 119 @Override 120 protected McpOptions createMcpOptions() { 121 return new McpOptions() 122 .setInstructions("A tiny notes service. Use 'publishNote' to add notes, read them via the " 123 + "'note:///{title}' resource template, subscribe for change notifications, and try " 124 + "'deleteNote' to see an elicitation (confirm) round-trip.") 125 .setCapabilities(new ServerCapabilities() 126 .setTools(new ToolCapability().setListChanged(true)) 127 .setPrompts(new PromptCapability().setListChanged(true)) 128 .setResources(new ResourceCapability().setSubscribe(true).setListChanged(true)) 129 .setCompletions(new CompletionCapability())); 130 } 131 132 // ================================================================================================= 133 // Tools 134 // ================================================================================================= 135 136 /** 137 * A plain tool that mutates server state and then notifies subscribers. 138 * 139 * <p> 140 * The {@code resourceUpdated(...)} call is the server-push half of the subscription feature: any client 141 * currently {@code listen}ing for changes to this note's URI receives an {@code onResourceUpdated} frame. 142 * The {@link McpSubscriptions} SPI is resolved from the per-request {@link BeanStore}. 143 */ 144 private static McpToolHandler publishNoteTool(NoteStore notes) { 145 return new McpToolHandler() { 146 @Override public McpToolSpec descriptor() { 147 return new McpToolSpec().setName("publishNote") 148 .setDescription("Stores a note under the given title and notifies subscribers of the change."); 149 } 150 @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) { 151 var title = String.valueOf(arguments.getOrDefault(TITLE_ARG, "")); 152 var body = String.valueOf(arguments.getOrDefault("body", "")); 153 notes.put(title, body); 154 ctx.getBean(McpSubscriptions.class).ifPresent(s -> s.resourceUpdated(NoteStore.uriFor(title))); 155 return McpToolOutcome.text("Stored note '" + title + "' (" + body.length() + " chars)."); 156 } 157 }; 158 } 159 160 /** 161 * A tool that pauses for confirmation before acting — the elicitation / Multi-Round-Trip-Request 162 * (MRTR, SEP-2322) flow. 163 * 164 * <p> 165 * On the first call there is no resume context, so the handler <i>throws</i> an {@code input_required} 166 * signal built by {@link ElicitationRequests#of(String, ElicitRequest, Object)}: it carries one question 167 * (id {@code "confirmDelete"}, asking for a boolean {@code confirm} field) plus a continuation token 168 * ({@code "delete:<title>"}) that the framework seals and echoes back. The id and the field name are 169 * deliberately distinct (rather than both {@code "confirm"}) so the two-level {@code (id, field)} lookup 170 * below is legible. When the client answers, the framework re-invokes this same handler with a populated 171 * {@link McpMrtrResumeContext}; the handler reads the answer and the continuation and finishes. 172 * 173 * <p> 174 * Note the title is recovered from the continuation on resume rather than from the arguments: carrying 175 * per-pause state through the continuation is the intended MRTR pattern. 176 * 177 * <p> 178 * The framework explicitly allows a resume with zero elicitation answers (a client that never asked the 179 * user, or a transport that dropped the answer), so the continuation and the {@code confirm} answer are 180 * both treated as possibly missing below — never dereferenced directly. {@link McpMrtrResumeContext#continuationAsString()} 181 * and {@link ElicitationResponses#getBoolean} are the decline/omission-safe accessors that make that 182 * possible without hand-rolled null checks. 183 */ 184 private static McpToolHandler deleteNoteTool(NoteStore notes) { 185 return new McpToolHandler() { 186 @Override public McpToolSpec descriptor() { 187 return new McpToolSpec().setName("deleteNote") 188 .setDescription("Deletes a note, after asking the caller to confirm (demonstrates elicitation)."); 189 } 190 @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) { 191 var resume = ctx.getBean(McpMrtrResumeContext.class); 192 if (resume.isEmpty()) { 193 // PAUSE: ask the caller to confirm. Execution stops here and control returns to the client. 194 var title = String.valueOf(arguments.getOrDefault(TITLE_ARG, "")); 195 var question = new ElicitRequest() 196 .setMessage("Really delete note '" + title + "'?") 197 .setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm deletion").build()); 198 throw ElicitationRequests.of("confirmDelete", question, DELETE_CONTINUATION_PREFIX + title); 199 } 200 // RESUME: the caller answered (or didn't). Recover state from the continuation; a missing 201 // continuation or a missing/declined "confirm" answer both cleanly mean "cancelled". 202 var rc = resume.get(); 203 var continuation = rc.continuationAsString(); 204 var title = continuation != null && continuation.startsWith(DELETE_CONTINUATION_PREFIX) 205 ? continuation.substring(DELETE_CONTINUATION_PREFIX.length()) : ""; 206 if (ElicitationResponses.getBoolean(rc, "confirmDelete", "confirm")) { 207 var removed = notes.remove(title); 208 return McpToolOutcome.text(removed 209 ? "Deleted note '" + title + "'." 210 : "There was no note titled '" + title + "'."); 211 } 212 return McpToolOutcome.text("Deletion of '" + title + "' was cancelled."); 213 } 214 }; 215 } 216 217 // ================================================================================================= 218 // Prompt 219 // ================================================================================================= 220 221 /** 222 * A prompt whose {@code title} argument declares a completer, so a client's {@code completion/complete} 223 * request for that argument is answered with matching existing note titles. 224 */ 225 private static McpPromptHandler summarizePrompt(NoteStore notes) { 226 var spec = new McpPromptSpec() 227 .setName("summarize") 228 .setDescription("Builds a prompt asking a model to summarize a stored note.") 229 .setArguments(List.of(new McpPromptArgument() 230 .setName(TITLE_ARG) 231 .setDescription("The title of the note to summarize.") 232 .setRequired(true) 233 .setCompleter(noteTitleCompleter(notes)))); 234 return new McpPromptHandler() { 235 @Override public McpPromptSpec descriptor() { return spec; } 236 @Override public McpPromptOutcome get(Map<String,Object> arguments, BeanStore ctx) { 237 var title = String.valueOf(arguments.getOrDefault(TITLE_ARG, "")); 238 var body = notes.get(title); 239 var text = body == null 240 ? "There is no note titled '" + title + "'." 241 : "Summarize the following note titled '" + title + "':\n\n" + body; 242 var message = new McpPromptMessage().setRole(McpRole.USER).setContent(McpContentBlock.text(text)); 243 return new McpPromptOutcome() 244 .setDescription("Summary prompt for note '" + title + "'.") 245 .setMessages(List.of(message)); 246 } 247 }; 248 } 249 250 // ================================================================================================= 251 // Resources 252 // ================================================================================================= 253 254 /** 255 * A fixed (non-templated) resource that lists all current note titles. 256 */ 257 private static McpResourceHandler noteIndexResource(NoteStore notes) { 258 var spec = new McpResourceSpec() 259 .setUri(NoteStore.SCHEME + "index") 260 .setName("note-index") 261 .setDescription("A plain-text list of all note titles.") 262 .setMimeType(TEXT_PLAIN); 263 return new McpResourceHandler() { 264 @Override public McpResourceSpec descriptor() { return spec; } 265 @Override public McpResourceOutcome read(String uri, BeanStore ctx) { 266 var titles = notes.titles(); 267 var text = titles.isEmpty() ? "(no notes yet)" : String.join("\n", titles); 268 return new McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri, TEXT_PLAIN, text))); 269 } 270 }; 271 } 272 273 /** 274 * A resource template ({@code note:///{title}}) that reads one note by title, and offers completions 275 * for its {@code title} variable. 276 * 277 * <p> 278 * An exact resource registration (here, {@code note:///index}) always beats a matching template, so 279 * {@code note:///index} reads the index above while {@code note:///groceries} routes here. 280 */ 281 private static McpResourceTemplateHandler noteTemplate(NoteStore notes) { 282 var spec = new McpResourceTemplateSpec() 283 .setUriTemplate(NoteStore.SCHEME + "{title}") 284 .setName("note") 285 .setDescription("Reads a single note by title.") 286 .setMimeType(TEXT_PLAIN); 287 var completer = noteTitleCompleter(notes); 288 return new McpResourceTemplateHandler() { 289 @Override public McpResourceTemplateSpec descriptor() { return spec; } 290 @Override public McpResourceOutcome read(String uri, Map<String,String> variables, BeanStore ctx) { 291 var title = variables.get(TITLE_ARG); 292 var body = notes.get(title); 293 var text = body == null ? "(no note titled '" + title + "')" : body; 294 return new McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri, TEXT_PLAIN, text))); 295 } 296 @Override public McpCompleter completer(String variableName) { 297 return eq(variableName, TITLE_ARG) ? completer : null; 298 } 299 }; 300 } 301 302 // ================================================================================================= 303 // Shared completion logic 304 // ================================================================================================= 305 306 /** 307 * A completer that suggests existing note titles beginning with the caller's partial input. Used by both 308 * the prompt argument and the resource-template variable, so one implementation drives every 309 * {@code completion/complete} response in this server. 310 */ 311 private static McpCompleter noteTitleCompleter(NoteStore notes) { 312 return (request, ctx) -> { 313 var prefix = request.getValue(); 314 var matches = notes.titles().stream().filter(t -> t.startsWith(prefix)).toList(); 315 return new McpCompletionResult().setValues(matches); 316 }; 317 } 318}