summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--defaults/web.xml14
-rw-r--r--source/net/yacy/http/servlets/MCPSearchServlet.java323
-rw-r--r--source/net/yacy/http/servlets/RAGProxyServlet.java59
3 files changed, 377 insertions, 19 deletions
diff --git a/defaults/web.xml b/defaults/web.xml
index 43a500cc1..d0e850709 100644
--- a/defaults/web.xml
+++ b/defaults/web.xml
@@ -53,6 +53,11 @@
<servlet>
<servlet-name>RAGProxyServlet</servlet-name>
<servlet-class>net.yacy.http.servlets.RAGProxyServlet</servlet-class>
+ </servlet>
+
+ <servlet>
+ <servlet-name>MCPSearchServlet</servlet-name>
+ <servlet-class>net.yacy.http.servlets.MCPSearchServlet</servlet-class>
</servlet>
<!-- servlet to provide searchresults via proxy -->
@@ -92,6 +97,15 @@
<servlet-name>RAGProxyServlet</servlet-name>
<url-pattern>/v1/chat/completions</url-pattern>
</servlet-mapping>
+
+ <servlet-mapping>
+ <servlet-name>MCPSearchServlet</servlet-name>
+ <url-pattern>/tools</url-pattern>
+ <url-pattern>/tools/list</url-pattern>
+ <url-pattern>/tools/list_changed</url-pattern>
+ <url-pattern>/tools/call</url-pattern>
+ </servlet-mapping>
+
<!-- eof hardcoded mappings -->
<!-- additional (optional) mappings -->
diff --git a/source/net/yacy/http/servlets/MCPSearchServlet.java b/source/net/yacy/http/servlets/MCPSearchServlet.java
new file mode 100644
index 000000000..93a2db1b9
--- /dev/null
+++ b/source/net/yacy/http/servlets/MCPSearchServlet.java
@@ -0,0 +1,323 @@
+/**
+ * MCPSearchServlet
+ * Copyright 2025 by Michael Peter Christen
+ * First released 11.10.2025 at https://yacy.net
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program in the file lgpl21.txt
+ * If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.yacy.http.servlets;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.solr.servlet.cache.Method;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+/**
+ * This servlet exposes a minimal Model Context Protocol (MCP) server that
+ * offers a single tool, `search`, backed by the YaCy search index.
+ * Clients can initialize a JSON-RPC session, list available tools and invoke
+ * the web search tool to retrieve results from the embedded Solr instance.
+ * The response payload follows the MCP conventions (JSON-RPC 2.0 with tool
+ * results wrapped inside a `content` array).
+ */
+public class MCPSearchServlet extends HttpServlet {
+
+ private static final long serialVersionUID = 433609077273989355L;
+
+ private static final String JSONRPC_VERSION = "2.0";
+ private static final String MCP_PROTOCOL_VERSION = "2024-11-05";
+ private static final String TOOL_NAME = "search";
+ private static final int DEFAULT_RESULT_COUNT = 10;
+ private static final int MAX_RESULT_COUNT = 100;
+
+ @Override
+ public void service(final ServletRequest request, final ServletResponse response) throws ServletException, IOException {
+ final HttpServletRequest hrequest = (HttpServletRequest) request;
+ final HttpServletResponse hresponse = (HttpServletResponse) response;
+
+ hresponse.setContentType("application/json;charset=utf-8");
+ hresponse.setHeader("Access-Control-Allow-Origin", "*");
+ hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
+ hresponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+
+ final Method reqMethod = Method.getMethod(hrequest.getMethod());
+ if (reqMethod == Method.OTHER) {
+ hresponse.setStatus(HttpServletResponse.SC_OK);
+ return;
+ }
+
+ final String body = readBody(request);
+ Object parsed = null;
+ if (body.length() == 0) {
+ parsed = new JSONObject();
+ } else try {
+ final JSONTokener tokener = new JSONTokener(body);
+ parsed = tokener.nextValue();
+ } catch (JSONException e) {
+ writeJsonResponse(hresponse, errorResponse(JSONObject.NULL, -32700, e.getMessage()));
+ }
+
+ try {
+ if (parsed instanceof JSONObject) {
+ if (((JSONObject) parsed).optString("method", "").length() == 0) {
+ String uri = hrequest.getRequestURI();
+ ((JSONObject) parsed).put("method", uri.substring(1));
+ }
+ final JSONObject responseObject = handleRequest((JSONObject) parsed);
+ if (responseObject != null) {
+ writeJsonResponse(hresponse, responseObject);
+ } else {
+ hresponse.setStatus(HttpServletResponse.SC_NO_CONTENT);
+ }
+ } else if (parsed instanceof JSONArray) {
+ final JSONArray requestArray = (JSONArray) parsed;
+ final JSONArray responseArray = new JSONArray();
+ for (int i = 0; i < requestArray.length(); i++) {
+ final Object entry = requestArray.get(i);
+ if (entry instanceof JSONObject) {
+ final JSONObject responseObject = handleRequest((JSONObject) entry);
+ if (responseObject != null) {
+ responseArray.put(responseObject);
+ }
+ }
+ }
+ if (responseArray.length() > 0) {
+ writeJsonResponse(hresponse, responseArray);
+ } else {
+ hresponse.setStatus(HttpServletResponse.SC_NO_CONTENT);
+ }
+ } else {
+ writeJsonResponse(hresponse, errorResponse(JSONObject.NULL, -32600, "Invalid JSON-RPC payload"));
+ }
+ } catch (JSONException e) {
+ writeJsonResponse(hresponse, errorResponse(JSONObject.NULL, -32700, e.getMessage()));
+ }
+ }
+
+ private JSONObject handleRequest(final JSONObject requestObject) {
+ final Object id = requestObject.optString("id", "0");
+ final String jsonrpc = requestObject.optString("jsonrpc", JSONRPC_VERSION);
+ final String method = requestObject.optString("method", "");
+
+ if (!JSONRPC_VERSION.equals(jsonrpc)) {
+ return errorResponse(id, -32600, "Unsupported JSON-RPC version");
+ }
+
+ if (method == null || method.isEmpty()) {
+ return errorResponse(id, -32600, "Missing method");
+ }
+
+ if (id == JSONObject.NULL || id == null) {
+ // Notification: acknowledge silently
+ if ("notifications/ping".equals(method)) {
+ return null;
+ }
+ // No response for other notifications either
+ return null;
+ }
+
+ switch (method) {
+ case "initialize":
+ return handleInitialize(id, requestObject.optJSONObject("params"));
+ case "tools/list":
+ return handleToolsList(id);
+ case "tools/call":
+ return handleToolsCall(id, requestObject.optJSONObject("params"));
+ default:
+ return errorResponse(id, -32601, "Unknown method: " + method);
+ }
+ }
+
+ private JSONObject handleInitialize(final Object id, final JSONObject params) {
+ final JSONObject result = new JSONObject(true);
+ try {
+ final JSONObject serverInfo = new JSONObject(true);
+ serverInfo.put("name", "YaCy MCP Web Search");
+ serverInfo.put("version", "1.0");
+
+ final JSONObject capabilities = new JSONObject(true);
+ final JSONObject toolsCapability = new JSONObject(true);
+ toolsCapability.put("list", true);
+ toolsCapability.put("call", true);
+ capabilities.put("tools", toolsCapability);
+
+ result.put("protocolVersion", MCP_PROTOCOL_VERSION);
+ result.put("serverInfo", serverInfo);
+ result.put("capabilities", capabilities);
+ } catch (JSONException e) {
+ return errorResponse(id, -32603, e.getMessage());
+ }
+ return successResponse(id, result);
+ }
+
+ private JSONObject handleToolsList(final Object id) {
+ try {
+ final JSONObject tool = new JSONObject(true);
+ tool.put("name", TOOL_NAME);
+ tool.put("description", "Search the YaCy index and return the most relevant web results.");
+
+ final JSONObject inputProperties = new JSONObject(true);
+ final JSONObject querySchema = new JSONObject(true);
+ querySchema.put("type", "string");
+ querySchema.put("description", "Search query to execute against the YaCy index.");
+ inputProperties.put("query", querySchema);
+
+ final JSONObject limitSchema = new JSONObject(true);
+ limitSchema.put("type", "integer");
+ limitSchema.put("minimum", 1);
+ limitSchema.put("maximum", MAX_RESULT_COUNT);
+ limitSchema.put("description", "Maximum number of results to return.");
+ inputProperties.put("limit", limitSchema);
+
+ final JSONObject includeSnippetSchema = new JSONObject(true);
+ includeSnippetSchema.put("type", "boolean");
+ includeSnippetSchema.put("description", "Include text snippets extracted from the indexed document content.");
+ inputProperties.put("include_snippet", includeSnippetSchema);
+
+ final JSONObject inputSchema = new JSONObject(true);
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", inputProperties);
+ final JSONArray required = new JSONArray();
+ required.put("query");
+ inputSchema.put("required", required);
+
+ tool.put("inputSchema", inputSchema);
+
+ final JSONObject outputSchema = new JSONObject(true);
+ outputSchema.put("type", "object");
+ final JSONObject outputProperties = new JSONObject(true);
+ final JSONObject resultsSchema = new JSONObject(true);
+ resultsSchema.put("type", "array");
+ resultsSchema.put("description", "Ordered list of search results.");
+ outputProperties.put("results", resultsSchema);
+ outputSchema.put("properties", outputProperties);
+ tool.put("outputSchema", outputSchema);
+
+ final JSONArray tools = new JSONArray();
+ tools.put(tool);
+
+ final JSONObject result = new JSONObject(true);
+ result.put("tools", tools);
+ return successResponse(id, result);
+ } catch (JSONException e) {
+ return errorResponse(id, -32603, e.getMessage());
+ }
+ }
+
+ private JSONObject handleToolsCall(final Object id, final JSONObject params) {
+ if (params == null) {
+ return errorResponse(id, -32602, "Missing params");
+ }
+ final String name = params.optString("name", "");
+ if (!TOOL_NAME.equals(name)) {
+ return errorResponse(id, -32601, "Unknown tool: " + name);
+ }
+
+ final JSONObject arguments = params.optJSONObject("arguments");
+ if (arguments == null) {
+ return errorResponse(id, -32602, "Missing tool arguments");
+ }
+
+ final String query = arguments.optString("query", "").trim();
+ if (query.isEmpty()) {
+ return errorResponse(id, -32602, "Tool argument 'query' must be a non-empty string");
+ }
+
+ int limit = arguments.optInt("limit", DEFAULT_RESULT_COUNT);
+ if (limit <= 0) {
+ limit = DEFAULT_RESULT_COUNT;
+ }
+ limit = Math.min(limit, MAX_RESULT_COUNT);
+ final boolean includeSnippet = arguments.optBoolean("include_snippet", true);
+
+ JSONArray results;
+ results = RAGProxyServlet.searchResults(query, limit, includeSnippet);
+
+ try {
+ final JSONObject payload = new JSONObject(true);
+ payload.put("results", results);
+
+ final JSONObject contentItem = new JSONObject(true);
+ contentItem.put("type", "json");
+ contentItem.put("json", payload);
+
+ final JSONArray content = new JSONArray();
+ content.put(contentItem);
+
+ final JSONObject result = new JSONObject(true);
+ result.put("content", content);
+ return successResponse(id, result);
+ } catch (JSONException e) {
+ return errorResponse(id, -32603, e.getMessage());
+ }
+ }
+
+ private static String readBody(final ServletRequest request) throws IOException {
+ final StringBuilder builder = new StringBuilder();
+ try (BufferedReader reader = request.getReader()) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line);
+ }
+ }
+ return builder.toString();
+ }
+
+ private static JSONObject successResponse(final Object id, final JSONObject result) {
+ final JSONObject response = new JSONObject(true);
+ try {
+ response.put("jsonrpc", JSONRPC_VERSION);
+ response.put("id", id);
+ response.put("result", result);
+ } catch (JSONException e) {
+ // As this method is only called with valid JSON objects we rethrow as unchecked
+ throw new IllegalStateException("Failed to construct success response", e);
+ }
+ return response;
+ }
+
+ private static JSONObject errorResponse(final Object id, final int code, final String message) {
+ final JSONObject response = new JSONObject(true);
+ try {
+ response.put("jsonrpc", JSONRPC_VERSION);
+ response.put("id", id);
+ final JSONObject error = new JSONObject(true);
+ error.put("code", code);
+ error.put("message", message);
+ response.put("error", error);
+ } catch (JSONException e) {
+ throw new IllegalStateException("Failed to construct error response", e);
+ }
+ return response;
+ }
+
+ private static void writeJsonResponse(final HttpServletResponse response, final Object payload) throws IOException {
+ final String serialized = payload instanceof JSONObject ? ((JSONObject) payload).toString()
+ : payload instanceof JSONArray ? ((JSONArray) payload).toString() : payload.toString();
+ response.getWriter().write(serialized);
+ }
+}
diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java
index 638bd7372..bbc891c75 100644
--- a/source/net/yacy/http/servlets/RAGProxyServlet.java
+++ b/source/net/yacy/http/servlets/RAGProxyServlet.java
@@ -28,8 +28,8 @@ import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
+import java.util.ArrayList;
import java.util.Iterator;
-import java.util.LinkedHashMap;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
@@ -49,6 +49,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import net.yacy.ai.OpenAIClient;
+import net.yacy.cora.federate.solr.SolrType;
import net.yacy.cora.federate.solr.connector.EmbeddedSolrConnector;
import net.yacy.search.Switchboard;
import net.yacy.search.schema.CollectionSchema;
@@ -141,18 +142,16 @@ public class RAGProxyServlet extends HttpServlet {
String query = this.searchWordsForPrompt(LLM_QUERY_MODEL, user);
out.print(responseLine("Searching for '" + query + "'\n\n").toString() + "\n");
out.flush();
- LinkedHashMap<String, String> searchResults = searchResults(query, 4);
- out.print(responseLine("Using the following sources for RAG:\n\n").toString() + "\n");
- out.flush();
- for (String s : searchResults.keySet()) {
- out.print(responseLine("- `" + s + "`\n").toString() + "\n");
- out.flush();
- }
+ JSONArray searchResults = searchResults(query, 4, true);
out.print(responseLine("\n").toString());
out.flush();
system += LLM_SYSTEM_PREFIX;
user += LLM_USER_PREFIX;
- for (String s : searchResults.values()) user += s + "\n\n";
+ for (int i = 0; i < searchResults.length(); i++) {
+ JSONObject r = searchResults.getJSONObject(i);
+ String snippet = r.optString("snippet", "");
+ user += snippet + "\n\n";
+ }
systemObject.put("content", system);
userObject.put("content", user);
@@ -200,9 +199,9 @@ public class RAGProxyServlet extends HttpServlet {
}
}
- public static LinkedHashMap<String, String> searchResults(String query, int count) {
- LinkedHashMap<String, String> a = new LinkedHashMap<>();
- if (query == null || query.length() == 0 || count == 0) return a;
+ public static JSONArray searchResults(String query, int count, final boolean includeSnippet) {
+ final JSONArray results = new JSONArray();
+ if (query == null || query.length() == 0 || count == 0) return results;
Switchboard sb = Switchboard.getSwitchboard();
EmbeddedSolrConnector connector = sb.index.fulltext().getDefaultEmbeddedConnector();
// construct query
@@ -213,7 +212,7 @@ public class RAGProxyServlet extends HttpServlet {
params.setFacet(false);
params.clearSorts();
params.setFields(CollectionSchema.sku.getSolrFieldName(), CollectionSchema.text_t.getSolrFieldName());
- params.setIncludeScore(false);
+ params.setIncludeScore(true);
params.set("df", CollectionSchema.text_t.getSolrFieldName());
// query the server
@@ -221,15 +220,37 @@ public class RAGProxyServlet extends HttpServlet {
final SolrDocumentList sdl = connector.getDocumentListByParams(params);
Iterator<SolrDocument> i = sdl.iterator();
while (i.hasNext()) {
- SolrDocument doc = i.next();
- String url = (String) doc.getFieldValue(CollectionSchema.sku.getSolrFieldName());
- String text = (String) doc.getFieldValue(CollectionSchema.text_t.getSolrFieldName());
- a.put(url, text);
+ try {
+ SolrDocument doc = i.next();
+ final JSONObject result = new JSONObject(true);
+ String url = (String) doc.getFieldValue(CollectionSchema.sku.getSolrFieldName());
+ result.put("url", url);
+ String title = getOneString(doc, CollectionSchema.title);
+ result.put("title", title == null ? url : title);
+ if (includeSnippet) {
+ String text = (String) doc.getFieldValue(CollectionSchema.text_t.getSolrFieldName());
+ result.put("snippet", text == null ? "" : text);
+ }
+ results.put(result);
+ } catch (JSONException e) {
+ // skip this result
+ }
}
- return a;
+ return results;
} catch (SolrException | IOException e) {
- return new LinkedHashMap<>();
+ return results;
+ }
+ }
+
+ private static String getOneString(SolrDocument doc, CollectionSchema field) {
+ assert field.isMultiValued();
+ assert field.getType() == SolrType.string || field.getType() == SolrType.text_general;
+ Object r = doc.getFieldValue(field.getSolrFieldName());
+ if (r == null) return "";
+ if (r instanceof ArrayList) {
+ return ((ArrayList<String>) r).get(0);
}
+ return r.toString();
}
private String searchWordsForPrompt(String model, String prompt) {