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 java.io.*;
020import java.util.*;
021import java.util.concurrent.*;
022
023import org.apache.juneau.bean.mcp.v20260728.*;
024import org.apache.juneau.marshall.marshaller.Json;
025import org.apache.juneau.rest.client.mcp.v20260728.*;
026
027/**
028 * A guided, end-to-end walkthrough of every {@link ExampleMcpServer} surface using the v2
029 * {@link McpClient}.
030 *
031 * <p>
032 * Run {@link #main(String[]) main} (optionally passing the server endpoint; defaults to
033 * {@code http://localhost:10000/}) after starting {@link ExampleServer}. Each numbered step prints
034 * what it is doing and what it got back, so the transcript reads top-to-bottom like a tutorial.
035 *
036 * <p>
037 * The client advertises the {@code elicitation} capability, which the server requires before it will
038 * pause a call for input; without it the {@code deleteNote} elicitation step would be rejected.
039 *
040 * <p>
041 * <b>Console output caveat:</b> every printed line below is produced by re-serializing the already-parsed
042 * response bean with {@code Json.of(...)}, purely for readable console output. That re-serialization drops
043 * the polymorphic content-block {@code "type"} discriminator (e.g. {@code "text"}/{@code "audio"}) the real
044 * wire format carries, so what you see here is not byte-for-byte what came off the wire.
045 */
046@SuppressWarnings({
047   "java:S106", // Example walkthrough intentionally prints to stdout; console output is the demo's deliverable.
048   "java:S112" // main(), run() and the demo helpers declare throws Exception so the walkthrough can propagate any client failure without boilerplate
049})
050public final class ExampleClient {
051
052   /** The {@code title} argument/variable name shared by the tool, prompt, and resource-template calls below. */
053   private static final String TITLE_ARG = "title";
054
055   /** The sample note title used throughout this walkthrough. */
056   private static final String GROCERIES_NOTE_TITLE = "groceries";
057
058   private ExampleClient() {}
059
060   /**
061    * Runs the walkthrough against a running {@link ExampleServer}.
062    *
063    * @param args Optional single argument: the server endpoint (defaults to {@code http://localhost:10000/}).
064    * @throws Exception If any step fails.
065    */
066   public static void main(String[] args) throws Exception {
067      var endpoint = args.length > 0 ? args[0] : "http://localhost:" + ExampleServer.DEFAULT_PORT + "/";
068      try (var client = connect(endpoint)) {
069         run(client);
070      }
071   }
072
073   /**
074    * Builds and connects a client that advertises the elicitation capability.
075    *
076    * @param endpoint The MCP server endpoint URL.
077    * @return A connected client (its mandatory {@code server/discover} handshake already done).
078    * @throws IOException If the connection or handshake fails.
079    */
080   @SuppressWarnings({
081      "resource" // returned client is owned and closed by the caller (see main() above).
082   })
083   public static McpClient connect(String endpoint) throws IOException {
084      return McpClient.connect(McpClient.builder()
085         .endpoint(endpoint)
086         .clientInfo(new Implementation().setName("juneau-notes-example-client").setVersion("1.0.0"))
087         // Required for the deleteNote elicitation step: the server only pauses for input when the
088         // client says it can answer.
089         .clientCapabilities(new ClientCapabilities().setElicitation(new ElicitationCapability())));
090   }
091
092   /**
093    * Executes each numbered step of the walkthrough against an already-connected client.
094    *
095    * @param client The connected MCP client.
096    * @throws Exception If any step fails.
097    */
098   public static void run(McpClient client) throws Exception {
099
100      section("1. server/discover — who are we talking to?");
101      var discovered = client.discoveredServer();
102      System.out.println("   server info:  " + Json.of(discovered.getServerInfo()));
103      System.out.println("   versions:     " + Json.of(discovered.getSupportedVersions()));
104      System.out.println("   capabilities: " + Json.of(discovered.getCapabilities()));
105      System.out.println("   instructions: " + discovered.getInstructions());
106
107      section("2. Discovery — list every advertised surface");
108      System.out.println("   tools:             " + Json.of(client.listTools().getTools()));
109      System.out.println("   prompts:           " + Json.of(client.listPrompts().getPrompts()));
110      System.out.println("   resources:         " + Json.of(client.listResources().getResources()));
111      System.out.println("   resourceTemplates: " + Json.of(client.listResourceTemplates().getResourceTemplates()));
112
113      section("3. tools/call publishNote — store a note (and notify subscribers)");
114      System.out.println("   -> " + client.callToolText("publishNote", Map.of(TITLE_ARG, GROCERIES_NOTE_TITLE, "body", "Milk, eggs, bread")));
115
116      section("4. resources/read note:///groceries — read it back via the template");
117      var read = client.readResource(NoteStore.uriFor(GROCERIES_NOTE_TITLE));
118      System.out.println("   " + Json.of(read.getContents()));
119
120      section("5. resources/read note:///index — the fixed index resource");
121      System.out.println("   " + Json.of(client.readResource(NoteStore.SCHEME + "index").getContents()));
122
123      section("6. completion/complete — complete the template's {title} variable for prefix 'gr'");
124      var ref = new ResourceTemplateReference().setUri(NoteStore.SCHEME + "{title}");
125      var completion = client.complete(ref, TITLE_ARG, "gr", null);
126      System.out.println("   suggestions: " + Json.of(completion.getCompletion()));
127
128      section("7. prompts/get summarize — render a prompt from the stored note");
129      var prompt = client.getPrompt("summarize", Map.of(TITLE_ARG, GROCERIES_NOTE_TITLE));
130      System.out.println("   " + Json.of(prompt.getMessages()));
131
132      section("8. subscriptions/listen — receive a live change notification");
133      runSubscriptionDemo(client);
134
135      section("9. tools/call deleteNote — an elicitation (confirm) round-trip, auto-answered");
136      var deleted = client.callToolWithElicitation("deleteNote", Map.of(TITLE_ARG, GROCERIES_NOTE_TITLE), requests -> {
137         // The server asked one or more questions; answer each with ACCEPT + confirm=true. A real client
138         // would present these to a user (the schema in each request says how to render the control).
139         System.out.println("   server asked: " + Json.of(requests));
140         var answers = new LinkedHashMap<String,ElicitResult>();
141         requests.keySet().forEach(id -> answers.put(id,
142            new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
143         return answers;
144      });
145      System.out.println("   -> " + deleted.firstText());
146
147      section("10. resources/read note:///index — confirm the note is gone");
148      System.out.println("   " + Json.of(client.readResource(NoteStore.SCHEME + "index").getContents()));
149
150      System.out.println("\nWalkthrough complete.");
151   }
152
153   /**
154    * Subscribes for changes to a note URI, publishes that note, and prints the change frame that arrives
155    * over the held-open SSE stream.
156    */
157   private static void runSubscriptionDemo(McpClient client) throws Exception {
158      var noteUri = NoteStore.uriFor("todo");
159      var updates = new LinkedBlockingQueue<String>();
160      var acknowledged = new CountDownLatch(1);
161
162      try (var handle = client.listen(
163         new SubscriptionFilter().setResourceSubscriptions(List.of(noteUri)),
164         new McpSubscriptionListener() {
165            @Override public void onAcknowledged(SubscriptionFilter honoredFilter) { acknowledged.countDown(); }
166            @Override public void onResourceUpdated(String uri) { updates.add(uri); }
167            @Override public void onError(Throwable t) { System.out.println("   subscription error: " + t); }
168         })) {
169         if (! acknowledged.await(10, TimeUnit.SECONDS))
170            System.out.println("   (warning: subscription was not acknowledged in time)");
171         else
172            System.out.println("   subscription acknowledged; publishing '" + noteUri + "' ...");
173
174         client.callTool("publishNote", Map.of(TITLE_ARG, "todo", "body", "Write MCP example"));
175
176         var updatedUri = updates.poll(10, TimeUnit.SECONDS);
177         if (updatedUri == null)
178            System.out.println("   (warning: no resources/updated notification arrived in time)");
179         else
180            System.out.println("   -> received resources/updated for: " + updatedUri);
181      }
182   }
183
184   private static void section(String title) {
185      System.out.println("\n=== " + title + " ===");
186   }
187}