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> &mdash; {@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> &mdash; {@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> &mdash; {@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> &mdash; the fixed {@code note:///index} resource lists all note titles.
050 *    <li><b>resource template + completion</b> &mdash; {@code note:///{title}} reads one note by title;
051 *       its {@code title} variable also offers completions.
052 *    <li><b>subscription</b> &mdash; {@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()} &mdash; <i>what</i> the server exposes (tools/prompts/resources).
060 *    <li>{@link #createMcpOptions()} &mdash; <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 &mdash; 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 &mdash; 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 &mdash; 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}