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}