From 9983ecb4fa84a70c83341bf054a4d41d953c9784 Mon Sep 17 00:00:00 2001 From: crschnick Date: Mon, 6 Apr 2026 08:35:54 +0000 Subject: [PATCH] Rework MCP server --- .../io/xpipe/app/beacon/mcp/AppMcpServer.java | 8 ++- .../xpipe/app/beacon/mcp/McpToolHandler.java | 7 +- .../io/xpipe/app/beacon/mcp/McpTools.java | 67 +++++-------------- .../xpipe/app/ext/ProcessControlProvider.java | 3 +- .../io/xpipe/app/secret/SecretQueryState.java | 2 +- .../xpipe/app/resources/mcp/list_files.json | 6 +- .../xpipe/app/resources/mcp/list_systems.json | 4 +- .../resources/mcp/open_terminal_inline.json | 37 ---------- .../io/xpipe/app/resources/mcp/prompt.md | 35 ++++++++++ 9 files changed, 70 insertions(+), 99 deletions(-) delete mode 100644 app/src/main/resources/io/xpipe/app/resources/mcp/open_terminal_inline.json create mode 100644 app/src/main/resources/io/xpipe/app/resources/mcp/prompt.md diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java b/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java index aa81bae85..b254dfc1f 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java @@ -46,6 +46,11 @@ public class AppMcpServer { (serverRequest) -> McpTransportContext.EMPTY, null); + var prompt = McpSchemaFiles.load("prompt.md"); + var effectivePrompt = AppPrefs.get().mcpAdditionalContext().getValue() != null ? + prompt.replace("__CUSTOM__", AppPrefs.get().mcpAdditionalContext().getValue()) : + prompt.replace("__CUSTOM__", ""); + McpSyncServer syncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo(AppNames.ofCurrent().getName(), AppProperties.get().getVersion()) .capabilities(McpSchema.ServerCapabilities.builder() @@ -53,7 +58,7 @@ public class AppMcpServer { .tools(true) .prompts(false) .build()) - .instructions(AppPrefs.get().mcpAdditionalContext().getValue()) + .instructions(effectivePrompt) .build(); var readOnlyTools = new ArrayList(); @@ -66,7 +71,6 @@ public class AppMcpServer { var mutationTools = new ArrayList(); mutationTools.add(McpTools.openTerminal()); - mutationTools.add(McpTools.openTerminalInline()); mutationTools.add(McpTools.createFile()); mutationTools.add(McpTools.writeFile()); mutationTools.add(McpTools.createDirectory()); diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java b/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java index 8e4d4decb..e47472507 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java @@ -2,6 +2,7 @@ package io.xpipe.app.beacon.mcp; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.process.ShellControl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.app.storage.DataStoreEntry; @@ -113,13 +114,13 @@ public interface McpToolHandler return b; } - public FilePath getFilePath(String key) throws BeaconClientException { + public FilePath getFilePath(ShellControl sc, String key) throws Exception { var s = getStringArgument(key); var path = FilePath.parse(s); if (path == null) { throw new BeaconClientException("Invalid argument for key " + key); } - return path; + return path.resolveTildeHome(sc.view().userHome()); } public DataStoreEntryRef getDataStoreRef(String name) throws BeaconClientException { @@ -130,7 +131,7 @@ public interface McpToolHandler if (found.size() > 1) { throw new BeaconClientException("Multiple connections found: " - + found.stream().map(DataStoreEntry::getName).toList()); + + found.stream().map(entry -> DataStorage.get().getStorePath(entry).toString()).toList()); } var e = found.getFirst(); diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java b/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java index d0584250b..9b11a6067 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java @@ -63,6 +63,9 @@ public final class McpTools { + syncToolSpecification.tool().description()) .collect(Collectors.joining("\n")); + var muEnabled = AppPrefs.get().enableMcpMutationTools().get(); + var muStatus = muEnabled ? "Right now, the mutation tools are enabled." : "Right now, the mutation tools are disabled. When you enable them in the settings menu, the MCP client might need a reconnect to see the changes."; + var text = """ The XPipe MCP server offers the following read-only tools: %s @@ -71,7 +74,8 @@ public final class McpTools { You can also enable the following potentially destructive tools in the settings menu: %s These tools can perform write operations and other actions that might be potentially destructive. - """.formatted(roList, muList); + %s + """.formatted(roList, muList, muStatus); return McpSchema.CallToolResult.builder() .addTextContent(text) @@ -138,10 +142,8 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var filter = req.getOptionalStringArgument("filter"); - var entries = filter.isPresent() - ? DataStorageQuery.queryUserInput(filter.get()) - : DataStorage.get().getStoreEntries(); + var filter = req.getStringArgument("filter"); + var entries = DataStorageQuery.queryUserInput(filter); var list = new ArrayList(); for (var e : entries) { @@ -192,10 +194,10 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.fileExists(path)) { @@ -218,12 +220,12 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); + var path = req.getFilePath(shellSession.getControl(), "path"); if (!fs.directoryExists(path)) { throw new BeaconClientException("Directory " + path + " does not exist"); @@ -246,12 +248,11 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); - var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); var pattern = req.getStringArgument("name"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.directoryExists(path)) { @@ -259,7 +260,7 @@ public final class McpTools { } var regex = Pattern.compile(DataStorageQuery.toRegex(pattern)); - try (var stream = recursive ? fs.listFilesRecursively(fs, path).stream() : fs.listFiles(fs, path)) { + try (var stream = fs.listFiles(fs, path)) { var list = stream.toList(); var builder = McpSchema.CallToolResult.builder(); list.stream() @@ -280,10 +281,10 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.fileExists(path) && !fs.directoryExists(path)) { @@ -321,10 +322,10 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); if (fs.fileExists(path)) { @@ -353,11 +354,11 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var content = req.getStringArgument("content"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); var b = content.getBytes(StandardCharsets.UTF_8); @@ -377,10 +378,10 @@ public final class McpTools { return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { - var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var path = req.getFilePath(shellSession.getControl(), "path"); var fs = new ConnectionFileSystem(shellSession.getControl()); if (fs.fileExists(path)) { @@ -406,12 +407,8 @@ public final class McpTools { var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); - var out = ProcessControlProvider.get().executeMcpCommand(shellSession.getControl(), command); - var formatted = CommandDialog.formatOutput(out); - - return McpSchema.CallToolResult.builder() - .addTextContent(formatted) - .build(); + var r = ProcessControlProvider.get().executeMcpCommand(shellSession.getControl(), command); + return r; })) .build(); } @@ -423,11 +420,11 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var script = req.getDataStoreRef("script"); - var directory = req.getFilePath("directory"); var arguments = req.getStringArgument("arguments"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); + var directory = req.getFilePath(shellSession.getControl(), "directory"); var clazz = Class.forName( AppExtensionManager.getInstance() @@ -479,34 +476,6 @@ public final class McpTools { .build(); } - public static McpServerFeatures.SyncToolSpecification openTerminalInline() throws IOException { - var tool = McpSchemaFiles.loadTool("open_terminal_inline.json"); - return McpServerFeatures.SyncToolSpecification.builder() - .tool(tool) - .callHandler(McpToolHandler.of((req) -> { - var system = req.getStringArgument("system"); - var directory = req.getOptionalStringArgument("directory"); - var shellStore = req.getShellStoreRef(system, true); - var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); - - var script = shellSession - .getControl() - .prepareTerminalOpen( - TerminalInitScriptConfig.ofName( - shellStore.get().getName()), - directory.isPresent() - ? WorkingDirectoryFunction.fixed(FilePath.parse(directory.get())) - : WorkingDirectoryFunction.none()); - - var json = JsonNodeFactory.instance.objectNode(); - json.put("command", script); - return McpSchema.CallToolResult.builder() - .structuredContent(JacksonMapper.getDefault().writeValueAsString(json)) - .build(); - })) - .build(); - } - public static McpServerFeatures.SyncToolSpecification toggleState() throws IOException { var tool = McpSchemaFiles.loadTool("toggle_state.json"); return McpServerFeatures.SyncToolSpecification.builder() diff --git a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java index aa7739ab2..a5a9ad3bf 100644 --- a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java @@ -1,5 +1,6 @@ package io.xpipe.app.ext; +import io.modelcontextprotocol.spec.McpSchema; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserStoreSessionTab; import io.xpipe.app.comp.base.ModalOverlay; @@ -58,7 +59,7 @@ public abstract class ProcessControlProvider { public abstract ShellDialect getEffectiveLocalDialect(); - public abstract String executeMcpCommand(ShellControl sc, String command) throws Exception; + public abstract McpSchema.CallToolResult executeMcpCommand(ShellControl sc, String command) throws Exception; public ShellDialect getNextFallbackDialect() { var av = getAvailableLocalDialects(); diff --git a/app/src/main/java/io/xpipe/app/secret/SecretQueryState.java b/app/src/main/java/io/xpipe/app/secret/SecretQueryState.java index af7c3b89d..dced2c961 100644 --- a/app/src/main/java/io/xpipe/app/secret/SecretQueryState.java +++ b/app/src/main/java/io/xpipe/app/secret/SecretQueryState.java @@ -20,7 +20,7 @@ public enum SecretQueryState { yield "Authentication operation was cancelled"; } case NON_INTERACTIVE -> { - yield "Session is not interactive but required user input for authentication"; + yield "Session is not interactive but required user input for authentication as no password is preconfigured"; } case FIXED_SECRET_WRONG -> { yield "Authentication failed: Provided authentication secret was not accepted by the server, probably because it is incorrect"; diff --git a/app/src/main/resources/io/xpipe/app/resources/mcp/list_files.json b/app/src/main/resources/io/xpipe/app/resources/mcp/list_files.json index 582f82faa..cba151e72 100644 --- a/app/src/main/resources/io/xpipe/app/resources/mcp/list_files.json +++ b/app/src/main/resources/io/xpipe/app/resources/mcp/list_files.json @@ -1,6 +1,6 @@ { "name": "list_files", - "description": "Lists files in a directory", + "description": "Lists files in a directory without recursion", "inputSchema": { "type": "object", "properties": { @@ -11,10 +11,6 @@ "path": { "type": "string", "description": "The directory to list the contents of" - }, - "recursive": { - "type": "boolean", - "description": "Whether to traverse subdirectories recursively or not" } }, "required": [ diff --git a/app/src/main/resources/io/xpipe/app/resources/mcp/list_systems.json b/app/src/main/resources/io/xpipe/app/resources/mcp/list_systems.json index dfa6c7b3b..b6a30440e 100644 --- a/app/src/main/resources/io/xpipe/app/resources/mcp/list_systems.json +++ b/app/src/main/resources/io/xpipe/app/resources/mcp/list_systems.json @@ -9,7 +9,9 @@ "description": "The system name filter, also supports globs" } }, - "required": [] + "required": [ + "filter" + ] }, "outputSchema": { "type": "object", diff --git a/app/src/main/resources/io/xpipe/app/resources/mcp/open_terminal_inline.json b/app/src/main/resources/io/xpipe/app/resources/mcp/open_terminal_inline.json deleted file mode 100644 index 82b383753..000000000 --- a/app/src/main/resources/io/xpipe/app/resources/mcp/open_terminal_inline.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "open_terminal_inline", - "description": "Returns a command to open a new terminal session in the client", - "inputSchema": { - "type": "object", - "properties": { - "system": { - "type": "string", - "description": "The system identifier" - }, - "directory": { - "type": "string", - "description": "The working directory" - } - }, - "required": [ - "system" - ] - }, - "annotations": { - "readOnlyHint": false, - "destructiveHint": true, - "openWorldHint": false - }, - "outputSchema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Run this command in the MCP client's local shell directly" - } - }, - "required": [ - "command" - ] - } -} \ No newline at end of file diff --git a/app/src/main/resources/io/xpipe/app/resources/mcp/prompt.md b/app/src/main/resources/io/xpipe/app/resources/mcp/prompt.md new file mode 100644 index 000000000..1c628056b --- /dev/null +++ b/app/src/main/resources/io/xpipe/app/resources/mcp/prompt.md @@ -0,0 +1,35 @@ +Answer the user's request using the relevant tool(s), if they are available. Check that all the required parameters for each tool call are provided or can reasonably be inferred from context. IF there are no relevant tools or there are missing values for required parameters, ask the user to supply these values; otherwise proceed with the tool calls. If the user provides a specific value for a parameter (for example provided in quotes), make sure to use that value EXACTLY. DO NOT make up values for or ask about optional parameters. Carefully analyze descriptive terms in the request as they may indicate required parameter values that should be included even if not explicitly quoted. + +When using a tool, follow the json schema very carefully and make sure to include ALL required properties. + +If a tool exists to do a task, use the tool instead of asking the user to manually take an action. + +Don't repeat yourself after a tool call, pick up where you left off. + +Never say the name of a tool to a user. For example, instead of saying that you'll use the run_command tool, say "I'll run the command". + +To obtain information about available tools and whether they are currently enabled, use the help tool. Tools can be disabled in the settings menu if they are considered mutation tools that can modify systems. + +Sudo elevation requires a password to be configured in XPipe. If sudo elevation fails, tell the user to add a password to the user identity of the system. + +When a user says that they want to connect to a certain system, DO NOT open a terminal with the open_terminal tool unless explicitly asked. Call the run_command tool with the command "pwd" to open a shell session and determine the current working directory. After the command is completed, say that the session has been established successfully. From then on, assume that all instructions refer to this system until the user connected to a different system. + +Don't call the run_command tool multiple times in parallel. Instead, run one command and wait for the output before running the next command. + +Use the write_file tool to edit files, unless the file write requires sudo. When editing files, group your changes by file. + +Use the read_file tool to read files, unless the file read requires sudo. + +Use the create_directory and create_file tools to create new directories and files, respectively. + +To locate a file or perform a search for a file, use the find_file tool. Do not use the find_file tool for recursive searches, run a command instead. + +To get metadata about a file or directory, use the get_file_info tool. + +To list files in a directory, use the list_files tool. + +If the user wants to open a terminal session in XPipe, use the open_terminal tool. + +Use the toggle_state tool when the user wants to start/stop a tunnel or service in XPipe. + +__CUSTOM__