diff --git a/Jenkinsfile b/Jenkinsfile index 442f936a2..f301c013c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -15,6 +15,7 @@ pipeline { sh ''' export JAVA_HOME="${NATIVE_TOOLS}${SEP}openjdk17_last" MVN="${COMMON_TOOLS}${SEP}maven3-latest/bin/mvn -V -Dmaven.repo.local=${WORKSPACE}/.repository/ -B -ntp" + ${MVN} -f ${WORKSPACE}/lsp4j-mcp/pom.xml versions:set -DnewVersion=$VERSION -DnewVersion=${VERSION} ${MVN} -f ${WORKSPACE}/quarkus.jdt.ext/pom.xml org.eclipse.tycho:tycho-versions-plugin:1.7.0:set-version -DnewVersion=${VERSION} -Dtycho.mode=maven ${MVN} -f ${WORKSPACE}/quarkus.ls.ext/com.redhat.quarkus.ls/pom.xml versions:set -DnewVersion=$VERSION -DnewVersion=${VERSION} -Dtycho.mode=maven ${MVN} -f ${WORKSPACE}/qute.jdt/pom.xml org.eclipse.tycho:tycho-versions-plugin:1.7.0:set-version -DnewVersion=${VERSION} -Dtycho.mode=maven @@ -31,6 +32,20 @@ pipeline { export JAVA_HOME="${NATIVE_TOOLS}${SEP}jdk11_last" MVN="${COMMON_TOOLS}${SEP}maven3-latest/bin/mvn -V -Dmaven.repo.local=${WORKSPACE}/.repository/" + pom=${WORKSPACE}/lsp4j-mcp/pom.xml + + pomVersion=$(grep "" ${pom} | head -1 | sed -e "s#.*\\(.\\+\\).*#\\1#") + if [[ ${pomVersion} == *"-SNAPSHOT" ]]; then + ${MVN} clean deploy ${mvnFlags} -f ${pom} + else + ${MVN} clean deploy ${mvnFlags} -DskipRemoteStaging=true -f ${pom} \ + -DstagingDescription="[${JOB_NAME} ${BUILD_TIMESTAMP} ${BUILD_NUMBER}] :: ${pomVersion} :: deploy to local" + ${MVN} nexus-staging:deploy-staged ${mvnFlags} -f ${pom} \ + -DstagingDescription="[${JOB_NAME} ${BUILD_TIMESTAMP} ${BUILD_NUMBER}] :: ${pomVersion} :: deploy to stage + close" + ${MVN} nexus-staging:release ${mvnFlags} -f ${pom} \ + -DstagingDescription="[${JOB_NAME} ${BUILD_TIMESTAMP} ${BUILD_NUMBER}] :: ${pomVersion} :: release" + fi + pom=${WORKSPACE}/quarkus.ls.ext/com.redhat.quarkus.ls/pom.xml pomVersion=$(grep "" ${pom} | head -1 | sed -e "s#.*\\(.\\+\\).*#\\1#") diff --git a/buildAll.bat b/buildAll.bat index a2cf1f784..323630e49 100755 --- a/buildAll.bat +++ b/buildAll.bat @@ -1,3 +1,4 @@ +cd lsp4j-mcp && .\mvnw.cmd clean install && cd .. ^ cd quarkus.jdt.ext && .\mvnw.cmd clean verify && cd .. ^ cd quarkus.ls.ext\com.redhat.quarkus.ls && .\mvnw.cmd clean verify && cd .. ^ cd qute.jdt && .\mvnw.cmd clean verify && cd .. ^ diff --git a/buildAll.sh b/buildAll.sh index 9bd815630..e3133c6fe 100755 --- a/buildAll.sh +++ b/buildAll.sh @@ -1,3 +1,4 @@ +cd lsp4j-mcp && ./mvnw clean verify && cd .. cd quarkus.jdt.ext && ./mvnw clean verify && cd .. cd quarkus.ls.ext/com.redhat.quarkus.ls && ./mvnw clean verify && cd ../.. cd qute.jdt && ./mvnw clean verify && cd .. diff --git a/lsp4j-mcp/.mvn/wrapper/MavenWrapperDownloader.java b/lsp4j-mcp/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 000000000..84d1e60d8 --- /dev/null +++ b/lsp4j-mcp/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +public final class MavenWrapperDownloader +{ + private static final String WRAPPER_VERSION = "3.2.0"; + + private static final boolean VERBOSE = Boolean.parseBoolean( System.getenv( "MVNW_VERBOSE" ) ); + + public static void main( String[] args ) + { + log( "Apache Maven Wrapper Downloader " + WRAPPER_VERSION ); + + if ( args.length != 2 ) + { + System.err.println( " - ERROR wrapperUrl or wrapperJarPath parameter missing" ); + System.exit( 1 ); + } + + try + { + log( " - Downloader started" ); + final URL wrapperUrl = new URL( args[0] ); + final String jarPath = args[1].replace( "..", "" ); // Sanitize path + final Path wrapperJarPath = Paths.get( jarPath ).toAbsolutePath().normalize(); + downloadFileFromURL( wrapperUrl, wrapperJarPath ); + log( "Done" ); + } + catch ( IOException e ) + { + System.err.println( "- Error downloading: " + e.getMessage() ); + if ( VERBOSE ) + { + e.printStackTrace(); + } + System.exit( 1 ); + } + } + + private static void downloadFileFromURL( URL wrapperUrl, Path wrapperJarPath ) + throws IOException + { + log( " - Downloading to: " + wrapperJarPath ); + if ( System.getenv( "MVNW_USERNAME" ) != null && System.getenv( "MVNW_PASSWORD" ) != null ) + { + final String username = System.getenv( "MVNW_USERNAME" ); + final char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); + Authenticator.setDefault( new Authenticator() + { + @Override + protected PasswordAuthentication getPasswordAuthentication() + { + return new PasswordAuthentication( username, password ); + } + } ); + } + try ( InputStream inStream = wrapperUrl.openStream() ) + { + Files.copy( inStream, wrapperJarPath, StandardCopyOption.REPLACE_EXISTING ); + } + log( " - Downloader complete" ); + } + + private static void log( String msg ) + { + if ( VERBOSE ) + { + System.out.println( msg ); + } + } + +} diff --git a/lsp4j-mcp/.mvn/wrapper/maven-wrapper.properties b/lsp4j-mcp/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..ac184013f --- /dev/null +++ b/lsp4j-mcp/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.4/apache-maven-3.9.4-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/lsp4j-mcp/README.md b/lsp4j-mcp/README.md new file mode 100644 index 000000000..5fcc2e638 --- /dev/null +++ b/lsp4j-mcp/README.md @@ -0,0 +1,514 @@ +# lsp4j-mcp + +**The Problem: Using an IDE and an MCP Client Together** + +When you want to use an **IDE** ([VS Code](https://code.visualstudio.com/), [Bob IDE](https://bob.ibm.com/docs/ide), [JetBrains](https://www.jetbrains.com/), etc.) alongside an **MCP client** ([Bob Shell](https://bob.ibm.com/docs/shell), [Claude Code](https://claude.com/fr/product/claude-code), etc.), traditional MCP server approaches create serious problems: they launch a separate Language Server instance instead of using your IDE's, leading to double parsing, file synchronization issues, settings mismatch, and invisible unsaved changes. + +**The lsp4j-mcp Solution** + +lsp4j-mcp solves this by embedding a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server **inside** your LSP4J-based language server. This allows your IDE and MCP clients to connect to the **same Language Server instance**, sharing diagnostics, code actions, and all LSP features without launching a separate process. + +## The Problem: MCP + LSP + IDE Don't Play Well Together + +Typically, MCP servers that work with Language Servers act as the **LSP client** and launch their own Language Server process. This works fine for standalone MCP clients (like Claude Desktop without an IDE), but creates serious problems when you want to use an **IDE** alongside an **MCP client** (Claude, [Bob Shell](https://bob.ibm.com/docs/shell), etc.): + +### Problems with the Traditional Approach + +When the MCP server launches its own Language Server instance: + +- ❌ **Separate Language Server process** - Not using the IDE's Language Server instance +- ❌ **Double parsing** - Two Language Server processes analyzing the same code +- ❌ **Duplicate configuration** - The MCP client must configure the LSP client separately +- ❌ **Custom commands management** - MCP server must handle language-specific commands +- ❌ **Settings mismatch** - Language Server settings differ from the IDE's settings +- ❌ **Unsaved changes invisible** - Content being edited but not saved is not accessible to the MCP server +- ❌ **Resource waste** - Two processes doing the same work + +### The lsp4j-mcp Solution + +**Reverse the relationship:** Instead of the MCP server launching the Language Server, the **Language Server embeds the MCP server**. + +``` +Traditional (problematic): lsp4j-mcp (solution): +MCP Server → Language Server IDE → Language Server (with embedded MCP Server) + ↑ ↑ + | | + IDE MCP Client (Claude) +``` + +**Benefits:** +- ✅ Single Language Server instance shared by IDE and MCP client (same as the IDE's) +- ✅ Same diagnostics, settings, and state everywhere +- ✅ Unsaved changes visible to AI assistant +- ✅ No duplicate parsing or configuration +- ✅ Works with any IDE ([Bob](https://bob.ibm.com/docs/ide), VS Code, JetBrains, etc.) + +## What is MCP? + +[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is a protocol that allows AI assistants to interact with external tools and data sources. By exposing LSP diagnostics and code actions through MCP, AI assistants can: + +- Query compilation errors, warnings, and linting issues in real-time +- Suggest fixes based on available code actions +- Understand project context better when answering questions + +## Use Case: Qute Template Validation with Bob + +Imagine you're working on a Qute template project in [Bob](https://bob.ibm.com/docs/ide) (an AI-powered IDE). You ask: + +> **User:** "Are there any errors in my Qute project?" + +![Bob prompt screenshot](docs/screenshots/bob-prompt.png) +*Screenshot: User asking Bob about errors in Qute project* + +Bob connects to the Qute Language Server via MCP and retrieves diagnostics: + +![Bob diagnostics result](docs/screenshots/bob-diagnostics.png) +*Screenshot: Bob displaying Qute template errors returned by the MCP server* + +This is possible because the Qute Language Server embeds an MCP server that exposes its diagnostics as tools. + +## Alternative MCP Integration Approaches + +For reference, here are other approaches to integrate MCP with language servers and their limitations: + +### Approach 1: MCP Server Launches Language Server (Traditional) + +**Example:** [stephanj/LSP4J-MCP](https://github.com/stephanj/LSP4J-MCP) + +The MCP server acts as the LSP client and launches its own Language Server process. + +**Why this doesn't work well with IDEs:** +- ❌ Creates a **separate Language Server instance** (not the IDE's) +- ❌ **Double parsing** and resource waste +- ❌ **File synchronization issues** - IDE and MCP server have different views +- ❌ **Unsaved changes invisible** - Only saved files are accessible +- ❌ **Settings mismatch** - Different configuration from the IDE +- ❌ **Custom commands** must be handled by the MCP server + +### Approach 2: IDE-based MCP Server + +**Example:** JetBrains IDE MCP Server ([docs](https://www.jetbrains.com/help/idea/mcp-server.html)) + +The IDE itself provides an MCP server that exposes IDE features (including LSP data) as tools. + +**Limitations:** +- ❌ Requires a specific IDE to be running (JetBrains only) +- ❌ Not portable to other editors (VS Code, [Bob](https://bob.ibm.com/docs/ide), etc.) +- ❌ Tied to IDE-specific extension points + +### Approach 3: Language Server Launches MCP Server ✅ (lsp4j-mcp) + +**Our solution:** + +``` +Step 1: IDE launches Language Server +──────────────────────────────────── + +┌────────────────┐ +│ IDE / Editor │ +│ (Bob, VS Code, │ +│ JetBrains) │ +└───────┬────────┘ + │ + │ Launches LS process (one per workspace) + ▼ +┌─────────────────────────────────────────────────────┐ +│ Language Server Process │ +│ │ +│ ┌────────────────────┐ │ +│ │ LSP Server │ │ +│ │ (Qute LS, JDT LS) │ │ +│ └────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + + +Step 2: Language Server embeds MCP Server +────────────────────────────────────────── + +┌────────────────┐ ┌────────────────┐ +│ IDE / Editor │ │ AI Assistant │ +│ (Bob, VS Code, │ │ (Claude) │ +│ JetBrains) │ └───────┬────────┘ +└───────┬────────┘ │ + │ │ + │ LSP Protocol │ MCP Protocol + ▼ ▼ +┌─────────────────────────────────────────────────────┐ +│ Language Server Process (per workspace) │ +│ │ +│ ┌────────────────────┐ ┌──────────────────┐ │ +│ │ LSP Server │◄────►│ MCP Server │ │ +│ │ (Qute LS, JDT LS) │ │ (lsp4j-mcp) │ │ +│ └────────────────────┘ └──────────────────┘ │ +│ │ │ │ +│ └───────────┬───────────────┘ │ +│ ▼ │ +│ Shared diagnostics, │ +│ code actions, cache │ +└─────────────────────────────────────────────────────┘ +``` + +**How it works:** + +1. **IDE launches the language server** (standard LSP workflow) + - One language server process per workspace/project + - IDE is the LSP client, controls the language server lifecycle + +2. **Language server embeds an MCP server** (using lsp4j-mcp) + - MCP server runs in the same process as the language server + - Exposes language server features as MCP tools + +3. **Multiple clients, one language server** + - IDE connects via LSP (for editor features) + - AI assistant connects via MCP (for diagnostics, code actions) + - Both share the same language server instance + +**Scaling:** +- N Bob windows → N language server processes → N MCP servers +- Each workspace gets its own language server + MCP server pair + +**Benefits:** +- ✅ Works with **any IDE** that supports LSP ([Bob](https://bob.ibm.com/docs/ide), VS Code, JetBrains, Emacs, etc.) +- ✅ **IDE controls the language server lifecycle** (launch, shutdown) +- ✅ Shares the same language server instance the IDE is using +- ✅ No file synchronization issues - single source of truth +- ✅ One MCP server per workspace/project +- ✅ Diagnostics are always in sync with the IDE +- ✅ Language server doesn't need to know about the IDE implementation + +**Key insight:** + +The **IDE launches the language server** (not the MCP server). The MCP server is just an additional interface embedded inside the language server process, allowing AI assistants to access the same data the IDE sees. + +## Getting Started + +### 1. Add Dependency + +Add lsp4j-mcp to your LSP4J-based language server: + +```xml + + com.redhat.lsp4j + lsp4j-mcp + 0.1.0-SNAPSHOT + +``` + +### 2. Create MCP Tools + +Define tools using annotations: + +```java +package com.example.tools; + +import com.redhat.lsp4j.mcp.annotations.*; +import com.redhat.lsp4j.mcp.tools.McpTool; +import org.eclipse.lsp4j.services.LanguageServer; + +public class MyCustomTool implements McpTool { + + @Inject + private LanguageServer languageServer; + + @Tool(description = "Get diagnostics for a file") + public List getDiagnostics( + @ToolArg(name = "uri", description = "File URI") String uri) { + + // Your tool implementation + return diagnostics; + } +} +``` + +### 3. Register via ServiceLoader + +Create `src/main/resources/META-INF/services/com.redhat.lsp4j.mcp.tools.McpTool`: + +``` +com.example.tools.MyCustomTool +com.redhat.lsp4j.mcp.tools.lsp.LspGetDiagnosticsTool +com.redhat.lsp4j.mcp.tools.lsp.LspGetCodeActionsTool +``` + +### 4. Launch MCP Server in Your Language Server + +```java +import com.redhat.lsp4j.mcp.server.LspMcpServer; + +public class MyLanguageServer implements LanguageServer { + + private LspMcpServer mcpServer; + + @Override + public CompletableFuture initialize(InitializeParams params) { + // ... your initialization code + + // Start MCP server + mcpServer = new LspMcpServer(this, 9339); + mcpServer.start(); + + return CompletableFuture.completedFuture(result); + } + + @Override + public CompletableFuture shutdown() { + if (mcpServer != null) { + mcpServer.stop(); + } + return CompletableFuture.completedFuture(null); + } +} +``` + +### 5. Connect from AI Assistant + +Configure your AI assistant (e.g., Claude Desktop) to connect to the MCP server: + +```json +{ + "mcpServers": { + "qute-ls": { + "type": "sse", + "url": "http://localhost:9339/mcp/sse" + } + } +} +``` + +## Built-in LSP Tools + +lsp4j-mcp provides generic LSP tools out of the box: + +### `get_diagnostics` + +Get compilation errors, warnings, and other diagnostics for a file. + +**Schema:** +```json +{ + "textDocument": { + "uri": "file:///path/to/file.html" + } +} +``` + +**Features:** +- Automatically calls `textDocument/didOpen` if the file isn't open +- Caches diagnostics published by the language server +- Cleans up with `textDocument/didClose` after execution + +### `get_code_actions` + +Get available code actions (quick fixes) at a position. + +**Schema:** +```json +{ + "textDocument": { + "uri": "file:///path/to/file.html" + }, + "position": { + "line": 10, + "character": 5 + } +} +``` + +## Annotation Reference + +### `@Tool` + +Marks a method as an MCP tool. + +```java +@Tool( + name = "custom_name", // Optional: tool name (default: camelCase → snake_case) + description = "Tool description" +) +public ReturnType methodName(params...) { } +``` + +### `@ToolArg` + +Describes a method parameter or nested object field. + +```java +public void myTool( + @ToolArg( + name = "paramName", // Optional: parameter name (default: arg0, arg1, ...) + description = "Parameter description" + ) String param) { } +``` + +For nested objects, annotate the **getters**: + +```java +public class Position { + private int line; + + @ToolArg(description = "Line number (0-based)") + public int getLine() { return line; } +} +``` + +### `@Inject` + +Injects dependencies into tool instances. + +```java +public class MyTool implements McpTool { + @Inject + private LanguageServer languageServer; + + @Inject + private McpCache cache; +} +``` + +Available dependencies: +- `LanguageServer` - The LSP4J language server instance +- `McpCache` - Diagnostic and file state cache + +### `@RequireDidOpen` + +Many LSP features require that a file be opened first: `textDocument/publishDiagnostics`, `textDocument/codeAction`, `textDocument/completion`, etc. The Language Server only processes and publishes diagnostics for files that have been opened via `textDocument/didOpen`. + +This annotation automatically calls `textDocument/didOpen` before tool execution **only if the file is not already open in the IDE**, and calls `textDocument/didClose` afterward to clean up. This ensures: +- MCP clients can query diagnostics for files that aren't currently open in the IDE +- Files already open in the IDE are not unnecessarily reopened (preserving IDE state) + +```java +@Tool(description = "Get diagnostics") +@RequireDidOpen( + uriParam = "textDocument.uri", // Path to URI in arguments + languageId = "qute-html" // Language ID for didOpen +) +public List getDiagnostics(TextDocumentIdentifier textDocument) { } +``` + +**How it works:** +1. Checks if the file is already open in the IDE (tracked by the Language Server) +2. If **not open**: calls `textDocument/didOpen` → executes tool → calls `textDocument/didClose` +3. If **already open**: executes tool directly (IDE manages the file lifecycle) + +## How It Works + +### JSON Schema Generation + +lsp4j-mcp automatically generates JSON schemas from your Java method signatures: + +```java +@Tool(description = "Example") +public void example( + @ToolArg(name = "text", description = "Input") String text, + @ToolArg(name = "position", description = "Position") Position position) { } +``` + +Generates: + +```json +{ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Input" + }, + "position": { + "type": "object", + "properties": { + "line": { "type": "integer", "description": "Line number" }, + "character": { "type": "integer", "description": "Character offset" } + }, + "required": ["line", "character"] + } + }, + "required": ["text", "position"] +} +``` + +### Argument Deserialization + +MCP arguments (JSON) are automatically deserialized into Java objects using Gson: + +```json +{ + "textDocument": { + "uri": "file:///path/to/file.html" + }, + "position": { + "line": 10, + "character": 5 + } +} +``` + +→ Deserialized to `TextDocumentIdentifier` and `Position` objects. + +**Important:** For Gson to deserialize correctly: +- Classes must have **private fields** (not just getters) +- Don't extend LSP4J classes directly (create standalone DTOs) + +Example: + +```java +// ✅ Good - Gson can deserialize +public class TextDocumentIdentifier { + private String uri; // Direct field access + + @ToolArg(description = "File URI") + public String getUri() { return uri; } + + public void setUri(String uri) { this.uri = uri; } +} + +// ❌ Bad - Gson can't find fields +public class TextDocumentIdentifier extends org.eclipse.lsp4j.TextDocumentIdentifier { + // Fields are in parent class, Gson won't find them +} +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Language Server Process │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ LSP Server │ │ MCP Server │ │ +│ │ │ │ (Undertow SSE) │ │ +│ │ ┌──────────────┐ │ │ │ │ +│ │ │ Text Doc Svc │◄┼─────────┤ Tool Handler │ │ +│ │ └──────────────┘ │ │ │ │ +│ │ │ │ ┌──────────────┐ │ │ +│ │ ┌──────────────┐ │ │ │ Tool Registry│ │ │ +│ │ │ Diagnostics │◄┼─────────┤ │ │ │ │ +│ │ └──────────────┘ │ │ │ @Tool scan │ │ │ +│ └──────────────────┘ │ │ @Inject DI │ │ │ +│ │ │ │ ServiceLoader│ │ │ +│ │ │ └──────────────┘ │ │ +│ ┌────────▼─────────┐ │ │ │ +│ │ McpCache │◄────────┤ Cache │ │ +│ │ - diagnostics │ │ │ │ +│ │ - didOpen state │ └──────────────────┘ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ▲ │ + │ LSP │ MCP (SSE) + │ ▼ + ┌────┴────┐ ┌──────────┐ + │ IDE │ │ Claude │ + └─────────┘ └──────────┘ +``` + +## Examples + +See the [Qute Language Server](https://github.com/redhat-developer/quarkus-ls/tree/master/qute.ls) for a complete integration example. + +## License + +EPL-2.0 + +## Contributing + +Contributions welcome! Please open an issue or PR. diff --git a/lsp4j-mcp/docs/screenshots/bob-diagnostics.png b/lsp4j-mcp/docs/screenshots/bob-diagnostics.png new file mode 100644 index 000000000..4e0b9eabc Binary files /dev/null and b/lsp4j-mcp/docs/screenshots/bob-diagnostics.png differ diff --git a/lsp4j-mcp/docs/screenshots/bob-prompt.png b/lsp4j-mcp/docs/screenshots/bob-prompt.png new file mode 100644 index 000000000..ca9d94df8 Binary files /dev/null and b/lsp4j-mcp/docs/screenshots/bob-prompt.png differ diff --git a/lsp4j-mcp/mvnw b/lsp4j-mcp/mvnw new file mode 100644 index 000000000..8d937f4c1 --- /dev/null +++ b/lsp4j-mcp/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/lsp4j-mcp/mvnw.cmd b/lsp4j-mcp/mvnw.cmd new file mode 100644 index 000000000..c4586b564 --- /dev/null +++ b/lsp4j-mcp/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/lsp4j-mcp/pom.xml b/lsp4j-mcp/pom.xml new file mode 100644 index 000000000..40fea12f9 --- /dev/null +++ b/lsp4j-mcp/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + com.redhat.lsp4j + lsp4j-mcp + 0.1.0-SNAPSHOT + jar + + LSP4J MCP Integration + Model Context Protocol (MCP) integration for LSP4J-based language servers + + + 17 + 17 + UTF-8 + 0.24.0 + 2.0.0-M2 + 5.10.0 + + + + + + org.eclipse.lsp4j + org.eclipse.lsp4j + ${lsp4j.version} + + + + + io.modelcontextprotocol.sdk + mcp + ${mcp.version} + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.datatype + * + + + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + ${mcp.version} + + + + + com.google.code.gson + gson + 2.10.1 + + + + + io.undertow + undertow-servlet + 2.3.17.Final + + + + + org.slf4j + slf4j-api + 2.0.9 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.mockito + mockito-core + 5.5.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + + diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/GSonUtils.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/GSonUtils.java new file mode 100644 index 000000000..5331412db --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/GSonUtils.java @@ -0,0 +1,49 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp; + +import java.util.HashMap; + +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; + +import com.google.gson.Gson; + +/** + * Utilities for JSON serialization of LSP4J objects. + */ +public class GSonUtils { + + private static final Gson LSP4J_GSON = new MessageJsonHandler(new HashMap<>()).getGson(); + + private GSonUtils() { + } + + /** + * Returns a Gson instance configured with all LSP4J type adapters. + * This includes adapters for Position, Range, Diagnostic, CodeAction, etc. + * + * @return a Gson instance configured for LSP4J objects + */ + public static Gson getGson() { + return LSP4J_GSON; + } + + /** + * Converts an object to JSON using LSP4J's Gson configuration. + * + * @param obj the object to serialize + * @return JSON string + */ + public static String toJson(Object obj) { + return LSP4J_GSON.toJson(obj); + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Inject.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Inject.java new file mode 100644 index 000000000..1c6754c5a --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Inject.java @@ -0,0 +1,26 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field for dependency injection. + * The MCP tool registry will inject the appropriate instance. + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Inject { +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/RequireDidOpen.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/RequireDidOpen.java new file mode 100644 index 000000000..74b958bde --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/RequireDidOpen.java @@ -0,0 +1,45 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that a tool requires the file to be opened before execution. + * + * The MCP framework will automatically: + * 1. Check if the file (specified by 'uri' parameter) is already opened + * 2. If not, simulate didOpen with file content from disk + * 3. Execute the tool method + * 4. If file was not originally opened, cleanup with didClose + * + * The tool method must have a parameter named 'uri' of type String. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireDidOpen { + + /** + * Name of the parameter containing the file URI. + * Default is "uri". + */ + String uriParam() default "uri"; + + /** + * Language ID to use when opening the file (e.g., "qute", "java"). + * Default is "qute". + */ + String languageId() default "qute"; +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Tool.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Tool.java new file mode 100644 index 000000000..4b79e9d71 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/Tool.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method as an MCP tool. + * The method will be automatically registered and exposed via MCP. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Tool { + + /** + * Tool description shown to the MCP client. + */ + String description(); + + /** + * Optional tool name. If not specified, method name is used. + */ + String name() default ""; +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/ToolArg.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/ToolArg.java new file mode 100644 index 000000000..95fc2bb46 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/annotations/ToolArg.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Describes a parameter of an MCP tool method or a getter method of a DTO. + */ +@Target({ElementType.PARAMETER, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ToolArg { + + /** + * Parameter name. If empty, uses the Java parameter name (requires -parameters compiler flag). + */ + String name() default ""; + + /** + * Parameter description shown to the MCP client. + */ + String description(); +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/cache/McpCache.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/cache/McpCache.java new file mode 100644 index 000000000..1510254f0 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/cache/McpCache.java @@ -0,0 +1,111 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.cache; + +import org.eclipse.lsp4j.Diagnostic; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Cache for MCP server that tracks: + * - Which files are currently opened in the IDE (via didOpen/didClose) + * - Diagnostics for all files (via publishDiagnostics) + * + * This cache allows MCP tools to: + * 1. Know if a file is already opened (to avoid unnecessary didOpen/didClose) + * 2. Retrieve diagnostics without re-parsing + */ +public class McpCache { + + /** + * Set of URIs for files currently opened in the IDE. + * Updated by didOpen/didClose interception. + */ + private final Set openedFiles = Collections.synchronizedSet(new HashSet<>()); + + /** + * Map of URI -> Diagnostics. + * Updated by publishDiagnostics interception. + * Thread-safe because publishDiagnostics can be called asynchronously. + */ + private final Map> diagnostics = new ConcurrentHashMap<>(); + + /** + * Called when a file is opened in the IDE. + * Intercepted from TextDocumentService.didOpen(). + * + * @param uri the file URI + */ + public void onDidOpen(String uri) { + openedFiles.add(uri); + } + + /** + * Called when a file is closed in the IDE. + * Intercepted from TextDocumentService.didClose(). + * + * @param uri the file URI + */ + public void onDidClose(String uri) { + openedFiles.remove(uri); + } + + /** + * Called when diagnostics are published by the language server. + * Intercepted from LanguageClient.publishDiagnostics(). + * + * @param uri the file URI + * @param diagnostics the diagnostics for this file + */ + public void putDiagnostics(String uri, List diagnostics) { + this.diagnostics.put(uri, new ArrayList<>(diagnostics)); + } + + /** + * Check if a file is currently opened in the IDE. + * + * @param uri the file URI + * @return true if the file is opened, false otherwise + */ + public boolean isOpened(String uri) { + return openedFiles.contains(uri); + } + + /** + * Get cached diagnostics for a file. + * + * @param uri the file URI + * @return the diagnostics, or empty list if not in cache + */ + public List getDiagnostics(String uri) { + return diagnostics.getOrDefault(uri, Collections.emptyList()); + } + + /** + * Remove diagnostics from cache. + * Useful for cleanup after temporary didOpen/didClose. + * + * @param uri the file URI + */ + public void removeDiagnostics(String uri) { + diagnostics.remove(uri); + } + + /** + * Clear all cache (for testing or reset). + */ + public void clear() { + openedFiles.clear(); + diagnostics.clear(); + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/LspMcpServer.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/LspMcpServer.java new file mode 100644 index 000000000..3eb5afe4b --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/LspMcpServer.java @@ -0,0 +1,352 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.undertow.Undertow; +import io.undertow.servlet.Servlets; +import io.undertow.servlet.api.DeploymentInfo; +import io.undertow.servlet.api.DeploymentManager; +import io.undertow.servlet.api.InstanceFactory; +import io.undertow.servlet.api.InstanceHandle; +import jakarta.servlet.ServletException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import tools.jackson.databind.json.JsonMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +/** + * Generic MCP (Model Context Protocol) Server for LSP4J-based language servers. + * + * This server exposes language server capabilities as MCP tools via HTTP/SSE, + * allowing AI assistants like Claude Code or Bob to interact with the language server. + * + * Architecture: + *
+ * AI Client (Claude Code, Bob)
+ *   ↕ HTTP/SSE (MCP protocol)
+ * LspMcpServer (Undertow on configurable port)
+ *   ↕ Java API
+ * Language Server (via MCP tools)
+ * 
+ * + * Usage: + *
+ * LspMcpServer mcpServer = LspMcpServer.builder()
+ *     .serverInfo("qute-ls", "1.0.0")
+ *     .port(9339)
+ *     .registerTool(new GetDiagnosticsTool(cache, languageServer))
+ *     .registerTool(new QuteDataModelTool(languageServer))
+ *     .build();
+ *
+ * mcpServer.start();
+ * 
+ */ +public class LspMcpServer { + + private static final Logger LOGGER = LoggerFactory.getLogger(LspMcpServer.class); + + private static final String DEFAULT_SSE_ENDPOINT = "/sse"; + private static final String DEFAULT_MESSAGE_ENDPOINT = "/mcp/message"; + private static final int DEFAULT_PORT = 9339; + + private final String serverName; + private final String serverVersion; + private final int port; + private final List tools; + + private Undertow undertowServer; + private McpSyncServer mcpServer; + private HttpServletSseServerTransportProvider transportProvider; + private boolean started = false; + + private LspMcpServer(Builder builder) { + this.serverName = builder.serverName; + this.serverVersion = builder.serverVersion; + this.port = builder.port; + this.tools = new ArrayList<>(builder.tools); + } + + /** + * Start the MCP server. + * + * The server will listen on http://localhost:{port} with: + * - /sse for SSE connections + * - /mcp/message for client messages + * + * @throws IllegalStateException if server is already started + */ + public void start() { + if (started) { + throw new IllegalStateException("MCP server already started"); + } + + LOGGER.info("Starting MCP Server: {} v{}", serverName, serverVersion); + + try { + // Create Jackson3 JSON mapper + JsonMapper jackson3Mapper = JsonMapper.builder().build(); + JacksonMcpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(jackson3Mapper); + + // Create HTTP/SSE transport provider (this is a Servlet) + transportProvider = HttpServletSseServerTransportProvider.builder() + .jsonMapper(mcpJsonMapper) + .messageEndpoint(DEFAULT_MESSAGE_ENDPOINT) + .sseEndpoint(DEFAULT_SSE_ENDPOINT) + .build(); + + // Build MCP server with capabilities and tools + var serverBuilder = io.modelcontextprotocol.server.McpServer.sync(transportProvider) + .serverInfo(serverName, serverVersion) + .jsonMapper(mcpJsonMapper) + .jsonSchemaValidator(new DefaultJsonSchemaValidator()) + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(true) + .build()); + + // Register all tools + LOGGER.info("Registering {} MCP tools", tools.size()); + for (McpToolRegistration toolReg : tools) { + LOGGER.info("Registering tool: {} - {}", toolReg.getName(), toolReg.getDescription()); + + serverBuilder.toolCall( + McpSchema.Tool.builder() + .name(toolReg.getName()) + .description(toolReg.getDescription()) + .inputSchema(toolReg.getInputSchema()) + .build(), + (exchange, request) -> { + try { + return toolReg.getHandler().execute(exchange, request); + } catch (Exception e) { + LOGGER.error("Error executing tool: " + toolReg.getName(), e); + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent( + "Error: " + e.getMessage() + ))) + .isError(true) + .build(); + } + } + ); + LOGGER.info("Registered MCP tool: {} - {}", toolReg.getName(), toolReg.getDescription()); + } + + mcpServer = serverBuilder.build(); + + // Configure Undertow servlet deployment + // Use singleton instance to maintain SSE sessions + final HttpServletSseServerTransportProvider singletonServlet = transportProvider; + + DeploymentInfo servletBuilder = Servlets.deployment() + .setClassLoader(LspMcpServer.class.getClassLoader()) + .setContextPath("/") + .setDeploymentName("lsp4j-mcp") + .addServlets( + Servlets.servlet("mcpServlet", HttpServletSseServerTransportProvider.class, + new InstanceFactory() { + @Override + public InstanceHandle createInstance() { + return new InstanceHandle() { + @Override + public HttpServletSseServerTransportProvider getInstance() { + return singletonServlet; + } + + @Override + public void release() { + // Never release - we manage lifecycle + } + }; + } + }) + .addMapping("/*") + .setAsyncSupported(true) + ); + + DeploymentManager manager = Servlets.defaultContainer().addDeployment(servletBuilder); + manager.deploy(); + + // Create and start Undertow server + undertowServer = Undertow.builder() + .addHttpListener(port, "localhost") + .setHandler(manager.start()) + .build(); + + undertowServer.start(); + + started = true; + LOGGER.info("MCP Server started successfully"); + LOGGER.info(" SSE endpoint: http://localhost:{}{}", port, DEFAULT_SSE_ENDPOINT); + LOGGER.info(" Message endpoint: http://localhost:{}{}", port, DEFAULT_MESSAGE_ENDPOINT); + + } catch (ServletException e) { + LOGGER.error("Failed to deploy MCP servlet", e); + throw new RuntimeException("Failed to start MCP server", e); + } catch (Exception e) { + LOGGER.error("Failed to start MCP server", e); + throw new RuntimeException("Failed to start MCP server", e); + } + } + + /** + * Stop the MCP server. + */ + public void stop() { + if (!started) { + return; + } + + LOGGER.info("Stopping MCP Server"); + try { + if (undertowServer != null) { + undertowServer.stop(); + } + if (transportProvider != null) { + transportProvider.close(); + } + if (mcpServer != null) { + mcpServer.close(); + } + started = false; + LOGGER.info("MCP Server stopped"); + } catch (Exception e) { + LOGGER.error("Error stopping MCP server", e); + } + } + + /** + * Check if the server is running. + */ + public boolean isStarted() { + return started; + } + + /** + * Create a new builder for LspMcpServer. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for LspMcpServer. + */ + public static class Builder { + private String serverName = "lsp4j-mcp-server"; + private String serverVersion = "1.0.0"; + private int port = DEFAULT_PORT; + private final McpToolRegistry registry = new McpToolRegistry(); + private final List tools = new ArrayList<>(); + + /** + * Set the server name and version. + */ + public Builder serverInfo(String name, String version) { + this.serverName = name; + this.serverVersion = version; + return this; + } + + /** + * Set the port to listen on. + */ + public Builder port(int port) { + this.port = port; + return this; + } + + /** + * Register a dependency for injection into tool providers. + */ + public Builder registerDependency(Class type, Object instance) { + registry.register(type, instance); + return this; + } + + /** + * Register an MCP tool manually (legacy support). + * @deprecated Use annotation-based tools with SPI instead + */ + @Deprecated + public Builder registerTool(String name, String description, Map inputSchema, McpToolHandler handler) { + this.tools.add(new McpToolRegistration(name, description, inputSchema, handler)); + return this; + } + + /** + * Build the LspMcpServer. + */ + public LspMcpServer build() { + // Auto-discover tool classes via SPI + loadToolsFromSPI(); + + // Add discovered tools to the tools list + tools.addAll(registry.getToolRegistrations()); + + return new LspMcpServer(this); + } + + private void loadToolsFromSPI() { + // Use ServiceLoader to discover MCP tools + ServiceLoader loader = + ServiceLoader.load(com.redhat.lsp4j.mcp.tools.McpTool.class); + + for (com.redhat.lsp4j.mcp.tools.McpTool tool : loader) { + LOGGER.info("Discovered MCP tool: {}", tool.getClass().getName()); + registry.scanAndRegister(tool); + } + } + } + + /** + * Tool registration. + */ + public static class McpToolRegistration { + private final String name; + private final String description; + private final Map inputSchema; + private final McpToolHandler handler; + + McpToolRegistration(String name, String description, Map inputSchema, McpToolHandler handler) { + this.name = name; + this.description = description; + this.inputSchema = inputSchema; + this.handler = handler; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Map getInputSchema() { + return inputSchema; + } + + public McpToolHandler getHandler() { + return handler; + } + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageClientWrapper.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageClientWrapper.java new file mode 100644 index 000000000..426b2ea53 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageClientWrapper.java @@ -0,0 +1,72 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import com.redhat.lsp4j.mcp.cache.McpCache; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.services.LanguageClient; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/** + * Generic wrapper for LanguageClient that intercepts publishDiagnostics + * to cache diagnostics for MCP tools. + * + * Uses dynamic proxy to avoid implementing all interface methods manually. + * Works with any LanguageClient implementation. + * + * @param The specific LanguageClient type to wrap + */ +public class McpLanguageClientWrapper { + + /** + * Create a wrapper for a LanguageClient that intercepts publishDiagnostics. + * + * @param The specific LanguageClient type + * @param clientClass The LanguageClient class + * @param delegate The LanguageClient instance to wrap + * @param cache The MCP cache for storing diagnostics + * @return A wrapped LanguageClient instance + */ + @SuppressWarnings("unchecked") + public static C wrap(Class clientClass, C delegate, McpCache cache) { + return (C) Proxy.newProxyInstance( + clientClass.getClassLoader(), + new Class[]{clientClass}, + new McpInvocationHandler<>(delegate, cache) + ); + } + + private static class McpInvocationHandler implements InvocationHandler { + private final C delegate; + private final McpCache cache; + + McpInvocationHandler(C delegate, McpCache cache) { + this.delegate = delegate; + this.cache = cache; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Intercept publishDiagnostics + if (method.getName().equals("publishDiagnostics") && args != null && args.length == 1) { + PublishDiagnosticsParams diagnostics = (PublishDiagnosticsParams) args[0]; + cache.putDiagnostics(diagnostics.getUri(), diagnostics.getDiagnostics()); + } + + // Delegate to the real client + return method.invoke(delegate, args); + } + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageServerWrapper.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageServerWrapper.java new file mode 100644 index 000000000..d747e0d13 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpLanguageServerWrapper.java @@ -0,0 +1,77 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import com.redhat.lsp4j.mcp.cache.McpCache; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/** + * Generic wrapper for LanguageServer that wraps the TextDocumentService + * to intercept didOpen/didClose for MCP caching. + * + * Uses dynamic proxy to avoid implementing all interface methods manually. + * Works with any LanguageServer implementation. + * + * @param The specific LanguageServer type to wrap + */ +public class McpLanguageServerWrapper { + + /** + * Create a wrapper for a LanguageServer that wraps TextDocumentService. + * + * @param The specific LanguageServer type + * @param serverClass The LanguageServer class + * @param delegate The LanguageServer instance to wrap + * @param cache The MCP cache for tracking file state + * @return A wrapped LanguageServer instance + */ + @SuppressWarnings("unchecked") + public static S wrap(Class serverClass, S delegate, McpCache cache) { + McpTextDocumentServiceWrapper wrappedTextDocService = new McpTextDocumentServiceWrapper( + delegate.getTextDocumentService(), + cache + ); + + return (S) Proxy.newProxyInstance( + serverClass.getClassLoader(), + new Class[]{serverClass}, + new McpInvocationHandler<>(delegate, wrappedTextDocService) + ); + } + + private static class McpInvocationHandler implements InvocationHandler { + private final S delegate; + private final McpTextDocumentServiceWrapper wrappedTextDocService; + + McpInvocationHandler(S delegate, McpTextDocumentServiceWrapper wrappedTextDocService) { + this.delegate = delegate; + this.wrappedTextDocService = wrappedTextDocService; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Intercept getTextDocumentService to return wrapped version + if (method.getName().equals("getTextDocumentService") && + TextDocumentService.class.isAssignableFrom(method.getReturnType())) { + return wrappedTextDocService; + } + + // Delegate all other methods to the real server + return method.invoke(delegate, args); + } + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpTextDocumentServiceWrapper.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpTextDocumentServiceWrapper.java new file mode 100644 index 000000000..aa8a6a66b --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpTextDocumentServiceWrapper.java @@ -0,0 +1,360 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.lsp4j.CallHierarchyIncomingCall; +import org.eclipse.lsp4j.CallHierarchyIncomingCallsParams; +import org.eclipse.lsp4j.CallHierarchyItem; +import org.eclipse.lsp4j.CallHierarchyOutgoingCall; +import org.eclipse.lsp4j.CallHierarchyOutgoingCallsParams; +import org.eclipse.lsp4j.CallHierarchyPrepareParams; +import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.CodeLens; +import org.eclipse.lsp4j.CodeLensParams; +import org.eclipse.lsp4j.ColorInformation; +import org.eclipse.lsp4j.ColorPresentation; +import org.eclipse.lsp4j.ColorPresentationParams; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.DocumentColorParams; +import org.eclipse.lsp4j.DocumentDiagnosticParams; +import org.eclipse.lsp4j.DocumentDiagnosticReport; +import org.eclipse.lsp4j.DocumentFormattingParams; +import org.eclipse.lsp4j.DocumentHighlight; +import org.eclipse.lsp4j.DocumentHighlightParams; +import org.eclipse.lsp4j.DocumentLink; +import org.eclipse.lsp4j.DocumentLinkParams; +import org.eclipse.lsp4j.DocumentOnTypeFormattingParams; +import org.eclipse.lsp4j.DocumentRangeFormattingParams; +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.FoldingRangeRequestParams; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.ImplementationParams; +import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintParams; +import org.eclipse.lsp4j.InlineValue; +import org.eclipse.lsp4j.InlineValueParams; +import org.eclipse.lsp4j.LinkedEditingRangeParams; +import org.eclipse.lsp4j.LinkedEditingRanges; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.LocationLink; +import org.eclipse.lsp4j.Moniker; +import org.eclipse.lsp4j.MonikerParams; +import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; +import org.eclipse.lsp4j.PrepareRenameParams; +import org.eclipse.lsp4j.PrepareRenameResult; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ReferenceParams; +import org.eclipse.lsp4j.RenameParams; +import org.eclipse.lsp4j.SelectionRange; +import org.eclipse.lsp4j.SelectionRangeParams; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensDelta; +import org.eclipse.lsp4j.SemanticTokensDeltaParams; +import org.eclipse.lsp4j.SemanticTokensParams; +import org.eclipse.lsp4j.SemanticTokensRangeParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.SymbolInformation; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.TypeDefinitionParams; +import org.eclipse.lsp4j.TypeHierarchyItem; +import org.eclipse.lsp4j.TypeHierarchyPrepareParams; +import org.eclipse.lsp4j.TypeHierarchySubtypesParams; +import org.eclipse.lsp4j.TypeHierarchySupertypesParams; +import org.eclipse.lsp4j.WillSaveTextDocumentParams; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.jsonrpc.messages.Either3; +import org.eclipse.lsp4j.services.TextDocumentService; + +import com.redhat.lsp4j.mcp.cache.McpCache; + +/** + * Wrapper for TextDocumentService that intercepts didOpen/didClose to track + * which files are opened in the IDE. + * + * All other methods are delegated to the wrapped service. + */ +public class McpTextDocumentServiceWrapper implements TextDocumentService { + + private final TextDocumentService delegate; + private final McpCache cache; + + public McpTextDocumentServiceWrapper(TextDocumentService delegate, McpCache cache) { + this.delegate = delegate; + this.cache = cache; + } + + @Override + public void didOpen(DidOpenTextDocumentParams params) { + // Track that this file is now opened + cache.onDidOpen(params.getTextDocument().getUri()); + + // Forward to the real language server + delegate.didOpen(params); + } + + @Override + public void didClose(DidCloseTextDocumentParams params) { + // Track that this file is now closed + cache.onDidClose(params.getTextDocument().getUri()); + + // Forward to the real language server + delegate.didClose(params); + } + + // All methods below are simple delegation - no interception needed + + @Override + public void didChange(DidChangeTextDocumentParams params) { + delegate.didChange(params); + } + + @Override + public void didSave(DidSaveTextDocumentParams params) { + delegate.didSave(params); + } + + @Override + public CompletableFuture, CompletionList>> completion(CompletionParams params) { + return delegate.completion(params); + } + + @Override + public CompletableFuture resolveCompletionItem(CompletionItem unresolved) { + return delegate.resolveCompletionItem(unresolved); + } + + @Override + public CompletableFuture hover(HoverParams params) { + return delegate.hover(params); + } + + @Override + public CompletableFuture signatureHelp(SignatureHelpParams params) { + return delegate.signatureHelp(params); + } + + @Override + public CompletableFuture, List>> definition( + DefinitionParams params) { + return delegate.definition(params); + } + + @Override + public CompletableFuture> references(ReferenceParams params) { + return delegate.references(params); + } + + @Override + public CompletableFuture> documentHighlight(DocumentHighlightParams params) { + return delegate.documentHighlight(params); + } + + @Override + public CompletableFuture>> documentSymbol( + DocumentSymbolParams params) { + return delegate.documentSymbol(params); + } + + @Override + public CompletableFuture>> codeAction(CodeActionParams params) { + return delegate.codeAction(params); + } + + @Override + public CompletableFuture resolveCodeAction(CodeAction unresolved) { + return delegate.resolveCodeAction(unresolved); + } + + @Override + public CompletableFuture> codeLens(CodeLensParams params) { + return delegate.codeLens(params); + } + + @Override + public CompletableFuture resolveCodeLens(CodeLens unresolved) { + return delegate.resolveCodeLens(unresolved); + } + + @Override + public CompletableFuture> formatting(DocumentFormattingParams params) { + return delegate.formatting(params); + } + + @Override + public CompletableFuture> rangeFormatting(DocumentRangeFormattingParams params) { + return delegate.rangeFormatting(params); + } + + @Override + public CompletableFuture> onTypeFormatting(DocumentOnTypeFormattingParams params) { + return delegate.onTypeFormatting(params); + } + + @Override + public CompletableFuture rename(RenameParams params) { + return delegate.rename(params); + } + + @Override + public CompletableFuture linkedEditingRange(LinkedEditingRangeParams params) { + return delegate.linkedEditingRange(params); + } + + @Override + public void willSave(WillSaveTextDocumentParams params) { + delegate.willSave(params); + } + + @Override + public CompletableFuture> willSaveWaitUntil(WillSaveTextDocumentParams params) { + return delegate.willSaveWaitUntil(params); + } + + @Override + public CompletableFuture, List>> typeDefinition( + TypeDefinitionParams params) { + return delegate.typeDefinition(params); + } + + @Override + public CompletableFuture, List>> implementation( + ImplementationParams params) { + return delegate.implementation(params); + } + + @Override + public CompletableFuture> documentColor(DocumentColorParams params) { + return delegate.documentColor(params); + } + + @Override + public CompletableFuture> colorPresentation(ColorPresentationParams params) { + return delegate.colorPresentation(params); + } + + @Override + public CompletableFuture> foldingRange(FoldingRangeRequestParams params) { + return delegate.foldingRange(params); + } + + @Override + public CompletableFuture> prepareRename( + PrepareRenameParams params) { + return delegate.prepareRename(params); + } + + @Override + public CompletableFuture> prepareCallHierarchy(CallHierarchyPrepareParams params) { + return delegate.prepareCallHierarchy(params); + } + + @Override + public CompletableFuture> callHierarchyIncomingCalls( + CallHierarchyIncomingCallsParams params) { + return delegate.callHierarchyIncomingCalls(params); + } + + @Override + public CompletableFuture> callHierarchyOutgoingCalls( + CallHierarchyOutgoingCallsParams params) { + return delegate.callHierarchyOutgoingCalls(params); + } + + @Override + public CompletableFuture> selectionRange(SelectionRangeParams params) { + return delegate.selectionRange(params); + } + + @Override + public CompletableFuture> documentLink(DocumentLinkParams params) { + return delegate.documentLink(params); + } + + @Override + public CompletableFuture documentLinkResolve(DocumentLink params) { + return delegate.documentLinkResolve(params); + } + + @Override + public CompletableFuture> prepareTypeHierarchy(TypeHierarchyPrepareParams params) { + return delegate.prepareTypeHierarchy(params); + } + + @Override + public CompletableFuture> typeHierarchySupertypes(TypeHierarchySupertypesParams params) { + return delegate.typeHierarchySupertypes(params); + } + + @Override + public CompletableFuture> typeHierarchySubtypes(TypeHierarchySubtypesParams params) { + return delegate.typeHierarchySubtypes(params); + } + + @Override + public CompletableFuture> inlayHint(InlayHintParams params) { + return delegate.inlayHint(params); + } + + @Override + public CompletableFuture resolveInlayHint(InlayHint unresolved) { + return delegate.resolveInlayHint(unresolved); + } + + @Override + public CompletableFuture> inlineValue(InlineValueParams params) { + return delegate.inlineValue(params); + } + + @Override + public CompletableFuture> moniker(MonikerParams params) { + return delegate.moniker(params); + } + + @Override + public CompletableFuture semanticTokensFull(SemanticTokensParams params) { + return delegate.semanticTokensFull(params); + } + + @Override + public CompletableFuture> semanticTokensFullDelta( + SemanticTokensDeltaParams params) { + return delegate.semanticTokensFullDelta(params); + } + + @Override + public CompletableFuture semanticTokensRange(SemanticTokensRangeParams params) { + return delegate.semanticTokensRange(params); + } + + @Override + public CompletableFuture diagnostic(DocumentDiagnosticParams params) { + return delegate.diagnostic(params); + } + +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolHandler.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolHandler.java new file mode 100644 index 000000000..d06141629 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolHandler.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Functional interface for MCP tool handlers. + */ +@FunctionalInterface +public interface McpToolHandler { + + /** + * Execute the tool with the given request. + * + * @param exchange the MCP server exchange + * @param request the tool call request + * @return the tool result + */ + McpSchema.CallToolResult execute(McpSyncServerExchange exchange, McpSchema.CallToolRequest request); +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolRegistry.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolRegistry.java new file mode 100644 index 000000000..dbb472d12 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/server/McpToolRegistry.java @@ -0,0 +1,445 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.services.LanguageServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.redhat.lsp4j.mcp.GSonUtils; +import com.redhat.lsp4j.mcp.annotations.Inject; +import com.redhat.lsp4j.mcp.annotations.RequireDidOpen; +import com.redhat.lsp4j.mcp.annotations.Tool; +import com.redhat.lsp4j.mcp.annotations.ToolArg; +import com.redhat.lsp4j.mcp.cache.McpCache; + +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Registry for MCP tools with dependency injection and automatic registration. + */ +public class McpToolRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger(McpToolRegistry.class); + + private final Map, Object> dependencies = new HashMap<>(); + private final List toolRegistrations = new ArrayList<>(); + + /** + * Register a dependency for injection. + */ + public void register(Class type, Object instance) { + dependencies.put(type, instance); + } + + /** + * Inject dependencies into a tool class instance and scan its @Tool methods. + */ + public void scanAndRegister(Object toolInstance) { + // Inject dependencies + injectDependencies(toolInstance); + + // Scan @Tool methods + for (Method method : toolInstance.getClass().getDeclaredMethods()) { + if (method.isAnnotationPresent(Tool.class)) { + registerTool(toolInstance, method); + } + } + } + + /** + * Get all registered tools. + */ + public List getToolRegistrations() { + return toolRegistrations; + } + + private void injectDependencies(Object instance) { + for (Field field : instance.getClass().getDeclaredFields()) { + if (field.isAnnotationPresent(Inject.class)) { + Object dependency = dependencies.get(field.getType()); + if (dependency == null) { + throw new IllegalStateException("No dependency registered for type: " + field.getType()); + } + field.setAccessible(true); + try { + field.set(instance, dependency); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to inject dependency: " + field.getName(), e); + } + } + } + } + + private void registerTool(Object toolInstance, Method method) { + Tool toolAnnotation = method.getAnnotation(Tool.class); + + // Determine tool name + String toolName = toolAnnotation.name(); + if (toolName.isEmpty()) { + toolName = camelToSnake(method.getName()); + } + + String description = toolAnnotation.description(); + + // Generate input schema + Map inputSchema = generateInputSchema(method); + + // Create handler + McpToolHandler handler = createHandler(toolInstance, method); + + LOGGER.info("Registered tool: {} from method {}.{}", toolName, + toolInstance.getClass().getSimpleName(), method.getName()); + + toolRegistrations.add(new LspMcpServer.McpToolRegistration(toolName, description, inputSchema, handler)); + } + + public Map generateInputSchema(Method method) { + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (Parameter param : method.getParameters()) { + // Get parameter name from @ToolArg annotation if present, otherwise use Java name (arg0, arg1, etc.) + String paramName = param.getName(); + ToolArg paramAnnotation = param.getAnnotation(ToolArg.class); + if (paramAnnotation != null && !paramAnnotation.name().isEmpty()) { + paramName = paramAnnotation.name(); + } + + // Check if parameter type has @ToolArg on its methods (like Position) + if (hasToolArgAnnotations(param.getType())) { + // Nested object - recurse into its methods + Map nestedProps = new LinkedHashMap<>(); + List nestedRequired = new ArrayList<>(); + for (Method getter : param.getType().getDeclaredMethods()) { + if (getter.isAnnotationPresent(ToolArg.class)) { + ToolArg argAnnotation = getter.getAnnotation(ToolArg.class); + String fieldName = getterToFieldName(getter.getName()); + nestedProps.put(fieldName, Map.of( + "type", javaTypeToJsonType(getter.getReturnType()), + "description", argAnnotation.description() + )); + nestedRequired.add(fieldName); + } + } + properties.put(paramName, Map.of( + "type", "object", + "properties", nestedProps, + "required", nestedRequired + )); + required.add(paramName); + } else { + // Simple parameter + ToolArg argAnnotation = param.getAnnotation(ToolArg.class); + String description = argAnnotation != null ? argAnnotation.description() : ""; + + properties.put(paramName, Map.of( + "type", javaTypeToJsonType(param.getType()), + "description", description + )); + + required.add(paramName); + } + } + + return Map.of( + "type", "object", + "properties", properties, + "required", required + ); + } + + private boolean hasToolArgAnnotations(Class type) { + for (Method method : type.getDeclaredMethods()) { + if (method.isAnnotationPresent(ToolArg.class)) { + return true; + } + } + return false; + } + + private String getterToFieldName(String getterName) { + if (getterName.startsWith("get") && getterName.length() > 3) { + return Character.toLowerCase(getterName.charAt(3)) + getterName.substring(4); + } + return getterName; + } + + private String javaTypeToJsonType(Class type) { + if (type == String.class) return "string"; + if (type == int.class || type == Integer.class) return "integer"; + if (type == long.class || type == Long.class) return "integer"; + if (type == double.class || type == Double.class) return "number"; + if (type == float.class || type == Float.class) return "number"; + if (type == boolean.class || type == Boolean.class) return "boolean"; + return "object"; + } + + private String camelToSnake(String camelCase) { + return camelCase.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + } + + private McpToolHandler createHandler(Object toolInstance, Method method) { + RequireDidOpen requireDidOpen = method.getAnnotation(RequireDidOpen.class); + + if (requireDidOpen != null) { + return new DidOpenInterceptorHandler(toolInstance, method, requireDidOpen); + } else { + return new SimpleMethodHandler(toolInstance, method); + } + } + + /** + * Simple handler that just invokes the method. + */ + private static class SimpleMethodHandler implements McpToolHandler { + private final Object toolInstance; + private final Method method; + + SimpleMethodHandler(Object toolInstance, Method method) { + this.toolInstance = toolInstance; + this.method = method; + } + + @Override + public McpSchema.CallToolResult execute(McpSyncServerExchange exchange, McpSchema.CallToolRequest request) { + try { + Map args = request.arguments(); + Object[] methodArgs = extractMethodArgs(method, args); + + Object result = method.invoke(toolInstance, methodArgs); + + String json = GSonUtils.toJson(result); + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(json))) + .isError(false) + .build(); + + } catch (Exception e) { + LOGGER.error("Error executing tool", e); + return errorResult(e.getMessage()); + } + } + + private Object[] extractMethodArgs(Method method, Map args) { + Parameter[] params = method.getParameters(); + Object[] methodArgs = new Object[params.length]; + + for (int i = 0; i < params.length; i++) { + Parameter param = params[i]; + // Use same logic as generateInputSchema to get parameter name + String paramName = param.getName(); + ToolArg paramAnnotation = param.getAnnotation(ToolArg.class); + if (paramAnnotation != null && !paramAnnotation.name().isEmpty()) { + paramName = paramAnnotation.name(); + } + Object value = args.get(paramName); + + // If value is null but param is not a primitive, try to construct from args + if (value == null && !param.getType().isPrimitive()) { + // Try to deserialize the whole args map into the parameter type + // This handles cases where MCP client sends flat structure but we expect nested objects + try { + value = GSonUtils.getGson().fromJson( + GSonUtils.getGson().toJson(args), + param.getType() + ); + } catch (Exception e) { + // If that fails, leave it null + } + } else if (value instanceof Map && !param.getType().equals(Map.class)) { + // Handle nested objects like Position when explicitly provided + value = GSonUtils.getGson().fromJson( + GSonUtils.getGson().toJson(value), + param.getType() + ); + } + + methodArgs[i] = value; + } + + return methodArgs; + } + + private McpSchema.CallToolResult errorResult(String message) { + Map error = Map.of("error", message); + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(GSonUtils.toJson(error)))) + .isError(true) + .build(); + } + } + + /** + * Handler that wraps method invocation with didOpen/didClose logic. + */ + private class DidOpenInterceptorHandler implements McpToolHandler { + private final Object toolInstance; + private final Method method; + private final RequireDidOpen annotation; + + DidOpenInterceptorHandler(Object toolInstance, Method method, RequireDidOpen annotation) { + this.toolInstance = toolInstance; + this.method = method; + this.annotation = annotation; + } + + @Override + public McpSchema.CallToolResult execute(McpSyncServerExchange exchange, McpSchema.CallToolRequest request) { + try { + Map args = request.arguments(); + String uri = extractUri(args); + + McpCache cache = (McpCache) dependencies.get(McpCache.class); + LanguageServer languageServer = (LanguageServer) dependencies.get(LanguageServer.class); + + boolean wasOpened = cache.isOpened(uri); + + // If file not opened, simulate didOpen + if (!wasOpened) { + LOGGER.info("File not opened, simulating didOpen: {}", uri); + + String content; + try { + String path = uri.replace("file:///", "").replace("file://", ""); + content = Files.readString(Paths.get(path)); + } catch (Exception e) { + return errorResult("Failed to read file: " + e.getMessage()); + } + + DidOpenTextDocumentParams didOpenParams = new DidOpenTextDocumentParams(); + TextDocumentItem textDocument = new TextDocumentItem(); + textDocument.setUri(uri); + textDocument.setLanguageId(annotation.languageId()); + textDocument.setVersion(1); + textDocument.setText(content); + didOpenParams.setTextDocument(textDocument); + + languageServer.getTextDocumentService().didOpen(didOpenParams); + + // Wait for publishDiagnostics to be called + // TODO: Use a proper notification mechanism instead of sleep + Thread.sleep(500); + } + + // Execute the actual tool method + Object[] methodArgs = extractMethodArgs(method, args); + Object result = method.invoke(toolInstance, methodArgs); + + // Cleanup if needed + if (!wasOpened) { + LOGGER.info("Cleaning up with didClose: {}", uri); + DidCloseTextDocumentParams didCloseParams = new DidCloseTextDocumentParams(); + TextDocumentIdentifier textDocument = new TextDocumentIdentifier(uri); + didCloseParams.setTextDocument(textDocument); + languageServer.getTextDocumentService().didClose(didCloseParams); + } + + String json = GSonUtils.toJson(result); + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(json))) + .isError(false) + .build(); + + } catch (Exception e) { + LOGGER.error("Error executing tool with didOpen interception", e); + return errorResult(e.getMessage()); + } + } + + private String extractUri(Map args) { + String uriParamName = annotation.uriParam(); + + // Support nested paths like "textDocument.uri" + String[] parts = uriParamName.split("\\."); + Object current = args; + + for (String part : parts) { + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + throw new IllegalArgumentException("Cannot navigate to " + uriParamName); + } + + if (current == null) { + throw new IllegalArgumentException("Missing required uri parameter: " + uriParamName); + } + } + + return current.toString(); + } + + public Object[] extractMethodArgs(Method method, Map args) { + Parameter[] params = method.getParameters(); + Object[] methodArgs = new Object[params.length]; + + for (int i = 0; i < params.length; i++) { + Parameter param = params[i]; + // Use same logic as generateInputSchema to get parameter name + String paramName = param.getName(); + ToolArg paramAnnotation = param.getAnnotation(ToolArg.class); + if (paramAnnotation != null && !paramAnnotation.name().isEmpty()) { + paramName = paramAnnotation.name(); + } + Object value = args.get(paramName); + + // If value is null but param is not a primitive, try to construct from args + if (value == null && !param.getType().isPrimitive()) { + // Try to deserialize the whole args map into the parameter type + // This handles cases where MCP client sends flat structure but we expect nested objects + try { + value = GSonUtils.getGson().fromJson( + GSonUtils.getGson().toJson(args), + param.getType() + ); + } catch (Exception e) { + // If that fails, leave it null + } + } else if (value instanceof Map && !param.getType().equals(Map.class)) { + // Handle nested objects like Position when explicitly provided + value = GSonUtils.getGson().fromJson( + GSonUtils.getGson().toJson(value), + param.getType() + ); + } + + methodArgs[i] = value; + } + + return methodArgs; + } + + private McpSchema.CallToolResult errorResult(String message) { + Map error = Map.of("error", message); + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(GSonUtils.toJson(error)))) + .isError(true) + .build(); + } + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/McpTool.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/McpTool.java new file mode 100644 index 000000000..e7ae21d23 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/McpTool.java @@ -0,0 +1,23 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.tools; + +/** + * Marker interface for MCP tools discovered via SPI. + * + * Classes implementing this interface will be automatically discovered + * and their @Tool annotated methods will be registered as MCP tools. + * + * This interface has no methods - it's purely for ServiceLoader discovery. + */ +public interface McpTool { +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/Position.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/Position.java new file mode 100644 index 000000000..ca37e2a0b --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/Position.java @@ -0,0 +1,50 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.tools; + +import com.redhat.lsp4j.mcp.annotations.ToolArg; + +/** + * Position with MCP annotations for automatic schema generation. + * Note: We don't extend LSP4J Position because Gson needs direct field access for deserialization. + */ +public class Position { + + private int line; + private int character; + + public Position() { + } + + public Position(int line, int character) { + this.line = line; + this.character = character; + } + + @ToolArg(description = "Line number (0-based)") + public int getLine() { + return line; + } + + public void setLine(int line) { + this.line = line; + } + + @ToolArg(description = "Character offset in line (0-based)") + public int getCharacter() { + return character; + } + + public void setCharacter(int character) { + this.character = character; + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/TextDocumentIdentifier.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/TextDocumentIdentifier.java new file mode 100644 index 000000000..76962db82 --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/TextDocumentIdentifier.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.tools; + +import com.redhat.lsp4j.mcp.annotations.ToolArg; + +/** + * TextDocumentIdentifier with MCP annotations for automatic schema generation. + * Note: We don't extend LSP4J TextDocumentIdentifier because Gson needs direct field access. + */ +public class TextDocumentIdentifier { + + private String uri; + + public TextDocumentIdentifier() { + } + + public TextDocumentIdentifier(String uri) { + this.uri = uri; + } + + @ToolArg(description = "File URI (e.g., file:///path/to/file)") + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetCodeActionsTool.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetCodeActionsTool.java new file mode 100644 index 000000000..e5e22712f --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetCodeActionsTool.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.tools.lsp; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.CodeAction; +import org.eclipse.lsp4j.CodeActionContext; +import org.eclipse.lsp4j.CodeActionParams; +import org.eclipse.lsp4j.Command; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageServer; + +import com.redhat.lsp4j.mcp.annotations.Inject; +import com.redhat.lsp4j.mcp.annotations.RequireDidOpen; +import com.redhat.lsp4j.mcp.annotations.Tool; +import com.redhat.lsp4j.mcp.annotations.ToolArg; +import com.redhat.lsp4j.mcp.cache.McpCache; +import com.redhat.lsp4j.mcp.tools.McpTool; +import com.redhat.lsp4j.mcp.tools.Position; +import com.redhat.lsp4j.mcp.tools.TextDocumentIdentifier; + +/** + * Generic LSP tool to get code actions at a position. Works with any + * LSP4J-based language server. + */ +public class LspGetCodeActionsTool implements McpTool { + + /** + * Request object for code action tool. + */ + public static class CodeActionRequest { + + private TextDocumentIdentifier textDocument; + private Position position; + + public CodeActionRequest() { + } + + public CodeActionRequest(TextDocumentIdentifier textDocument, Position position) { + this.textDocument = textDocument; + this.position = position; + } + + @ToolArg(description = "Text document identifier") + public TextDocumentIdentifier getTextDocument() { + return textDocument; + } + + public void setTextDocument(TextDocumentIdentifier textDocument) { + this.textDocument = textDocument; + } + + @ToolArg(description = "Position in the file") + public Position getPosition() { + return position; + } + + public void setPosition(Position position) { + this.position = position; + } + } + + @Inject + private McpCache cache; + + @Inject + private LanguageServer languageServer; + + @Tool(description = "Get code actions at a given position in a file") + @RequireDidOpen(uriParam = "arg0.textDocument.uri") + public List> getCodeActions(CodeActionRequest request) throws Exception { + Position position = request.getPosition(); + String uri = request.getTextDocument().getUri(); + + // Get diagnostics at this position + List allDiagnostics = cache.getDiagnostics(uri); + List diagnosticsAtPosition = filterDiagnosticsAtPosition(allDiagnostics, position); + + // Build CodeActionParams + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new org.eclipse.lsp4j.TextDocumentIdentifier(uri)); + org.eclipse.lsp4j.Position lspPosition = new org.eclipse.lsp4j.Position(position.getLine(), position.getCharacter()); + params.setRange(new Range(lspPosition, lspPosition)); + + CodeActionContext context = new CodeActionContext(); + context.setDiagnostics(diagnosticsAtPosition); + params.setContext(context); + + // Call textDocument/codeAction + return languageServer.getTextDocumentService().codeAction(params).get(5, TimeUnit.SECONDS); + } + + private List filterDiagnosticsAtPosition(List diagnostics, Position position) { + if (diagnostics == null) { + return new ArrayList<>(); + } + + return diagnostics.stream().filter(d -> isPositionInRange(position, d.getRange())).collect(Collectors.toList()); + } + + private boolean isPositionInRange(Position position, Range range) { + if (range == null) { + return false; + } + + org.eclipse.lsp4j.Position start = range.getStart(); + org.eclipse.lsp4j.Position end = range.getEnd(); + + // Check if position is after or at start + if (position.getLine() < start.getLine()) { + return false; + } + if (position.getLine() == start.getLine() && position.getCharacter() < start.getCharacter()) { + return false; + } + + // Check if position is before or at end + if (position.getLine() > end.getLine()) { + return false; + } + if (position.getLine() == end.getLine() && position.getCharacter() > end.getCharacter()) { + return false; + } + + return true; + } +} diff --git a/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetDiagnosticsTool.java b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetDiagnosticsTool.java new file mode 100644 index 000000000..acbc0e36c --- /dev/null +++ b/lsp4j-mcp/src/main/java/com/redhat/lsp4j/mcp/tools/lsp/LspGetDiagnosticsTool.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.tools.lsp; + +import java.util.List; + +import org.eclipse.lsp4j.Diagnostic; + +import com.redhat.lsp4j.mcp.annotations.Inject; +import com.redhat.lsp4j.mcp.annotations.RequireDidOpen; +import com.redhat.lsp4j.mcp.annotations.Tool; +import com.redhat.lsp4j.mcp.annotations.ToolArg; +import com.redhat.lsp4j.mcp.cache.McpCache; +import com.redhat.lsp4j.mcp.tools.McpTool; +import com.redhat.lsp4j.mcp.tools.TextDocumentIdentifier; + +/** + * Generic LSP tool to get diagnostics for a file. + * Works with any LSP4J-based language server. + */ +public class LspGetDiagnosticsTool implements McpTool { + + @Inject + private McpCache cache; + + @Tool(description = "Get diagnostics for a file") + @RequireDidOpen(uriParam = "textDocument.uri") + public List getDiagnostics( + @ToolArg(name = "textDocument", description = "Text document identifier") TextDocumentIdentifier textDocument) { + return cache.getDiagnostics(textDocument.getUri()); + } +} diff --git a/lsp4j-mcp/src/main/resources/META-INF/services/com.redhat.lsp4j.mcp.tools.McpTool b/lsp4j-mcp/src/main/resources/META-INF/services/com.redhat.lsp4j.mcp.tools.McpTool new file mode 100644 index 000000000..066425007 --- /dev/null +++ b/lsp4j-mcp/src/main/resources/META-INF/services/com.redhat.lsp4j.mcp.tools.McpTool @@ -0,0 +1,3 @@ +# MCP Tool classes - no interface required, just annotate methods with @Tool +com.redhat.lsp4j.mcp.tools.lsp.LspGetDiagnosticsTool +com.redhat.lsp4j.mcp.tools.lsp.LspGetCodeActionsTool diff --git a/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpJsonSchemaGenerationTest.java b/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpJsonSchemaGenerationTest.java new file mode 100644 index 000000000..916cea68d --- /dev/null +++ b/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpJsonSchemaGenerationTest.java @@ -0,0 +1,357 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.redhat.lsp4j.mcp.annotations.Tool; +import com.redhat.lsp4j.mcp.annotations.ToolArg; +import com.redhat.lsp4j.mcp.tools.McpTool; + +/** + * Test JSON Schema generation from @Tool and @ToolArg annotations. + * Compares generated schemas with expected JSON to see exactly what MCP clients receive. + */ +class McpJsonSchemaGenerationTest { + + private McpToolRegistry registry; + private Gson gson; + + @BeforeEach + void setUp() { + registry = new McpToolRegistry(); + gson = new GsonBuilder().setPrettyPrinting().create(); + } + + @Test + void testSimpleStringParameter_withName() throws Exception { + class SimpleTool implements McpTool { + @Tool(description = "A simple tool") + public String execute(@ToolArg(name = "input", description = "Input string") String input) { + return input; + } + } + + String actualJson = generateSchemaJson(SimpleTool.class, "execute", String.class); + + String expectedJson = """ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Input string" + } + }, + "required": [ + "input" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + @Test + void testSimpleParameter_withoutName_generatesArg0() throws Exception { + class SimpleTool implements McpTool { + @Tool(description = "Test tool") + public String execute(String unnamed) { + return unnamed; + } + } + + String actualJson = generateSchemaJson(SimpleTool.class, "execute", String.class); + + String expectedJson = """ + { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "" + } + }, + "required": [ + "arg0" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + @Test + void testNestedObject_withToolArgOnGetters() throws Exception { + class NestedDTO { + @ToolArg(description = "The URI") + public String getUri() { + return null; + } + + @ToolArg(description = "The line number") + public int getLine() { + return 0; + } + } + + class ToolWithNested implements McpTool { + @Tool(description = "Tool with nested object") + public void execute(@ToolArg(name = "request", description = "The request") NestedDTO request) { + } + } + + String actualJson = generateSchemaJson(ToolWithNested.class, "execute", NestedDTO.class); + + String expectedJson = """ + { + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "The URI" + }, + "line": { + "type": "integer", + "description": "The line number" + } + }, + "required": [ + "uri", + "line" + ] + } + }, + "required": [ + "request" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + @Test + void testNestedObject_separateRequiredArrays_bugFix() throws Exception { + // CRITICAL: Verifies the bug fix where nested required arrays + // were incorrectly sharing the same list as the root required array + class NestedDTO { + @ToolArg(description = "Required field 1") + public String getField1() { + return null; + } + + @ToolArg(description = "Required field 2") + public String getField2() { + return null; + } + } + + class ToolWithNested implements McpTool { + @Tool(description = "Test") + public void execute(@ToolArg(name = "request", description = "Request") NestedDTO request) { + } + } + + String actualJson = generateSchemaJson(ToolWithNested.class, "execute", NestedDTO.class); + + // The bug was: nested required contained ["request", "field1", "field2"] + // The fix ensures: root required = ["request"], nested required = ["field1", "field2"] + String expectedJson = """ + { + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "field1": { + "type": "string", + "description": "Required field 1" + }, + "field2": { + "type": "string", + "description": "Required field 2" + } + }, + "required": [ + "field1", + "field2" + ] + } + }, + "required": [ + "request" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + @Test + void testMultipleParameters() throws Exception { + class MultiParamTool implements McpTool { + @Tool(description = "Test") + public void execute( + @ToolArg(name = "param1", description = "First") String p1, + @ToolArg(name = "param2", description = "Second") int p2) { + } + } + + String actualJson = generateSchemaJson(MultiParamTool.class, "execute", String.class, int.class); + + String expectedJson = """ + { + "type": "object", + "properties": { + "param1": { + "type": "string", + "description": "First" + }, + "param2": { + "type": "integer", + "description": "Second" + } + }, + "required": [ + "param1", + "param2" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + @Test + void testTypeMapping_allPrimitives() throws Exception { + class TypeTool implements McpTool { + @Tool(description = "Test") + public void execute( + @ToolArg(name = "str", description = "String param") String s, + @ToolArg(name = "i", description = "Int param") int i, + @ToolArg(name = "l", description = "Long param") long l, + @ToolArg(name = "d", description = "Double param") double d, + @ToolArg(name = "f", description = "Float param") float f, + @ToolArg(name = "b", description = "Boolean param") boolean b) { + } + } + + String actualJson = generateSchemaJson(TypeTool.class, "execute", + String.class, int.class, long.class, double.class, float.class, boolean.class); + + String expectedJson = """ + { + "type": "object", + "properties": { + "str": { + "type": "string", + "description": "String param" + }, + "i": { + "type": "integer", + "description": "Int param" + }, + "l": { + "type": "integer", + "description": "Long param" + }, + "d": { + "type": "number", + "description": "Double param" + }, + "f": { + "type": "number", + "description": "Float param" + }, + "b": { + "type": "boolean", + "description": "Boolean param" + } + }, + "required": [ + "str", + "i", + "l", + "d", + "f", + "b" + ] + } + """; + + assertJsonEquals(expectedJson, actualJson); + } + + // Helper methods + + private String generateSchemaJson(Class toolClass, String methodName, Class... paramTypes) + throws Exception { + Method method = toolClass.getMethod(methodName, paramTypes); + Map schema = generateInputSchema(method); + return gson.toJson(schema); + } + + @SuppressWarnings("unchecked") + private Map generateInputSchema(Method method) throws Exception { + Method generateMethod = McpToolRegistry.class.getDeclaredMethod("generateInputSchema", Method.class); + generateMethod.setAccessible(true); + return (Map) generateMethod.invoke(registry, method); + } + + @SuppressWarnings("unchecked") + private void assertJsonEquals(String expectedJson, String actualJson) { + // Parse both JSON strings + Object expected = gson.fromJson(expectedJson, Object.class); + Object actual = gson.fromJson(actualJson, Object.class); + + // Normalize "required" arrays (sort them) since reflection order is not guaranteed + normalizeRequiredArrays(expected); + normalizeRequiredArrays(actual); + + // Compare objects directly (Map.equals() ignores key order) + assertEquals(expected, actual, + "\nExpected JSON:\n" + gson.toJson(expected) + "\n\nActual JSON:\n" + gson.toJson(actual)); + } + + @SuppressWarnings("unchecked") + private void normalizeRequiredArrays(Object obj) { + if (obj instanceof Map) { + Map map = (Map) obj; + // Sort "required" array if present + if (map.containsKey("required") && map.get("required") instanceof List) { + List required = (List) map.get("required"); + required.sort(String::compareTo); + } + // Recursively normalize nested objects + for (Object value : map.values()) { + normalizeRequiredArrays(value); + } + } else if (obj instanceof List) { + for (Object item : (List) obj) { + normalizeRequiredArrays(item); + } + } + } +} diff --git a/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpMethodArgsDeserializationTest.java b/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpMethodArgsDeserializationTest.java new file mode 100644 index 000000000..8bc24d870 --- /dev/null +++ b/lsp4j-mcp/src/test/java/com/redhat/lsp4j/mcp/server/McpMethodArgsDeserializationTest.java @@ -0,0 +1,274 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.lsp4j.mcp.server; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.redhat.lsp4j.mcp.annotations.Tool; +import com.redhat.lsp4j.mcp.annotations.ToolArg; +import com.redhat.lsp4j.mcp.cache.McpCache; +import com.redhat.lsp4j.mcp.tools.McpTool; +import com.redhat.lsp4j.mcp.tools.Position; +import com.redhat.lsp4j.mcp.tools.TextDocumentIdentifier; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for extractMethodArgs - validates that MCP arguments (Map) + * are correctly deserialized into Java method parameters via Gson. + */ +class McpMethodArgsDeserializationTest { + + private McpToolRegistry registry; + private McpCache cache; + + @BeforeEach + void setUp() { + registry = new McpToolRegistry(); + cache = new McpCache(); + registry.register(McpCache.class, cache); + } + + @Test + void testSimpleStringParameter() { + class SimpleTool implements McpTool { + String receivedValue = null; + + @Tool(description = "Test") + public void execute(@ToolArg(name = "input", description = "Input") String input) { + this.receivedValue = input; + } + } + + SimpleTool tool = new SimpleTool(); + registry.scanAndRegister(tool); + + // Simulate MCP client sending arguments + Map args = new HashMap<>(); + args.put("input", "hello world"); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + assertEquals("hello world", tool.receivedValue); + } + + @Test + void testTextDocumentIdentifier_nestedMap() { + class TestTool implements McpTool { + TextDocumentIdentifier receivedDoc = null; + + @Tool(description = "Test") + public void execute( + @ToolArg(name = "textDocument", description = "Doc") TextDocumentIdentifier textDocument) { + this.receivedDoc = textDocument; + } + } + + TestTool tool = new TestTool(); + registry.scanAndRegister(tool); + + // Simulate MCP client sending: {textDocument={uri=file:///path/to/file}} + Map textDocMap = new HashMap<>(); + textDocMap.put("uri", "file:///C:/Users/Test/file.html"); + + Map args = new HashMap<>(); + args.put("textDocument", textDocMap); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + // CRITICAL TEST: Verify TextDocumentIdentifier.uri was correctly deserialized + assertNotNull(tool.receivedDoc, "TextDocumentIdentifier should not be null"); + assertEquals("file:///C:/Users/Test/file.html", tool.receivedDoc.getUri(), + "URI should be correctly deserialized from nested map"); + } + + static class NestedDTO { + private Position position; + + @ToolArg(description = "Position in file") + public Position getPosition() { + return position; + } + + public void setPosition(Position position) { + this.position = position; + } + } + + @Test + void testNestedObject_withPosition() { + class TestTool implements McpTool { + NestedDTO receivedData = null; + + @Tool(description = "Test") + public void execute(@ToolArg(name = "data", description = "Data") NestedDTO data) { + this.receivedData = data; + } + } + + TestTool tool = new TestTool(); + registry.scanAndRegister(tool); + + // Nested structure: {data={position={line=5, character=10}}} + Map posMap = new HashMap<>(); + posMap.put("line", 5); + posMap.put("character", 10); + + Map dataMap = new HashMap<>(); + dataMap.put("position", posMap); + + Map args = new HashMap<>(); + args.put("data", dataMap); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + assertNotNull(tool.receivedData); + assertNotNull(tool.receivedData.getPosition()); + assertEquals(5, tool.receivedData.getPosition().getLine()); + assertEquals(10, tool.receivedData.getPosition().getCharacter()); + } + + @Test + void testParameterWithoutName_arg0() { + class TestTool implements McpTool { + String receivedValue = null; + + @Tool(description = "Test") + public void execute(String unnamed) { + this.receivedValue = unnamed; + } + } + + TestTool tool = new TestTool(); + registry.scanAndRegister(tool); + + // Without @ToolArg(name=...), parameter is named "arg0" + Map args = new HashMap<>(); + args.put("arg0", "test value"); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + assertEquals("test value", tool.receivedValue); + } + + @Test + void testMultipleParameters() { + class TestTool implements McpTool { + String receivedStr = null; + int receivedInt = 0; + TextDocumentIdentifier receivedDoc = null; + + @Tool(description = "Test") + public void execute( + @ToolArg(name = "str", description = "") String str, + @ToolArg(name = "num", description = "") int num, + @ToolArg(name = "doc", description = "") TextDocumentIdentifier doc) { + this.receivedStr = str; + this.receivedInt = num; + this.receivedDoc = doc; + } + } + + TestTool tool = new TestTool(); + registry.scanAndRegister(tool); + + Map docMap = new HashMap<>(); + docMap.put("uri", "file:///multi.html"); + + Map args = new HashMap<>(); + args.put("str", "hello"); + args.put("num", 42); + args.put("doc", docMap); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + assertEquals("hello", tool.receivedStr); + assertEquals(42, tool.receivedInt); + assertNotNull(tool.receivedDoc); + assertEquals("file:///multi.html", tool.receivedDoc.getUri()); + } + + @Test + void testPrimitiveTypes() { + class TestTool implements McpTool { + int receivedInt = 0; + double receivedDouble = 0.0; + boolean receivedBool = false; + + @Tool(description = "Test") + public void execute( + @ToolArg(name = "i", description = "") int i, + @ToolArg(name = "d", description = "") double d, + @ToolArg(name = "b", description = "") boolean b) { + this.receivedInt = i; + this.receivedDouble = d; + this.receivedBool = b; + } + } + + TestTool tool = new TestTool(); + registry.scanAndRegister(tool); + + Map args = new HashMap<>(); + args.put("i", 123); + args.put("d", 3.14); + args.put("b", true); + + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder() + .name("execute") + .arguments(args) + .build(); + + LspMcpServer.McpToolRegistration toolReg = registry.getToolRegistrations().get(0); + toolReg.getHandler().execute(null, request); + + assertEquals(123, tool.receivedInt); + assertEquals(3.14, tool.receivedDouble, 0.001); + assertTrue(tool.receivedBool); + } +} diff --git a/qute.ls/com.redhat.qute.ls/pom.xml b/qute.ls/com.redhat.qute.ls/pom.xml index ec9e074ca..1dbf41cc0 100644 --- a/qute.ls/com.redhat.qute.ls/pom.xml +++ b/qute.ls/com.redhat.qute.ls/pom.xml @@ -41,7 +41,8 @@ 0.24.0 3.30.1 5.6.1 - 2.18.2 + 2.0.0-M2 + 2.3.17.Final @@ -185,12 +186,55 @@ ${junit.version} test - + - com.fasterxml.jackson.dataformat + tools.jackson.dataformat jackson-dataformat-yaml - ${jackson-dataformat-yaml.version} - + 3.0.3 + + + + com.redhat.lsp4j + lsp4j-mcp + 0.1.0-SNAPSHOT + + + + io.modelcontextprotocol.sdk + mcp + ${mcp.version} + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.datatype + * + + + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + ${mcp.version} + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.datatype + * + + + + + + io.undertow + undertow-servlet + ${undertow.version} + diff --git a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/QuteServerLauncher.java b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/QuteServerLauncher.java index 1291ff66b..dfca9d425 100644 --- a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/QuteServerLauncher.java +++ b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/QuteServerLauncher.java @@ -1,92 +1,129 @@ -/******************************************************************************* -* Copyright (c) 2021 Red Hat Inc. and others. -* All rights reserved. This program and the accompanying materials -* which accompanies this distribution, and is available at -* http://www.eclipse.org/legal/epl-v20.html -* -* SPDX-License-Identifier: EPL-2.0 -* -* Contributors: -* Red Hat Inc. - initial API and implementation -*******************************************************************************/ -package com.redhat.qute.ls; - -import java.io.InputStream; -import java.io.OutputStream; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.function.Function; - -import org.eclipse.lsp4j.jsonrpc.Launcher; -import org.eclipse.lsp4j.jsonrpc.MessageConsumer; -import org.eclipse.lsp4j.launch.LSPLauncher.Builder; -import org.eclipse.lsp4j.services.LanguageClient; -import org.eclipse.lsp4j.services.LanguageServer; - -import com.redhat.qute.ls.api.QuteLanguageClientAPI; -import com.redhat.qute.ls.commons.ParentProcessWatcher; - -/** - * Qute server launcher - * - */ -public class QuteServerLauncher { - - /** - * Main entry point for the server. System properties may influence the - * behavior: - *
    - * watchParentProcess: if defined and value is false then do not watch - * for the parent process otherwise if parent process is dead then stop this - * server. - *
- *
    - * runAsync: if defined and value is true then received message are - * processed in a separate thread than the LSP4J thread. - *
- * - * @param args - */ - public static void main(String[] args) { - QuteLanguageServer server = new QuteLanguageServer(); - Function wrapper; - wrapper = it -> it; - if ("true".equals(System.getProperty("runAsync"))) { - wrapper = it -> msg -> CompletableFuture.runAsync(() -> it.consume(msg)); - } - if (!"false".equals(System.getProperty("watchParentProcess"))) { - wrapper = new ParentProcessWatcher(server, wrapper); - } - Launcher launcher = createServerLauncher(server, System.in, System.out, - Executors.newCachedThreadPool(), wrapper); - - server.setClient(launcher.getRemoteProxy()); - launcher.startListening(); - } - - /** - * Create a new Launcher for a language server and an input and output stream. - * Threads are started with the given executor service. The wrapper function is - * applied to the incoming and outgoing message streams so additional message - * handling such as validation and tracing can be included. - * - * @param server - the server that receives method calls from the - * remote client - * @param in - input stream to listen for incoming messages - * @param out - output stream to send outgoing messages - * @param executorService - the executor service used to start threads - * @param wrapper - a function for plugging in additional message - * consumers - */ - public static Launcher createServerLauncher(LanguageServer server, InputStream in, OutputStream out, - ExecutorService executorService, Function wrapper) { - return new Builder().setLocalService(server).setRemoteInterface(QuteLanguageClientAPI.class) // Set - // client - // as - // Quarkus - // language - // client - .setInput(in).setOutput(out).setExecutorService(executorService).wrapMessages(wrapper).create(); - } -} +/******************************************************************************* +* Copyright (c) 2021 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package com.redhat.qute.ls; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; + +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.launch.LSPLauncher.Builder; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.LanguageServer; + +import com.redhat.lsp4j.mcp.cache.McpCache; +import com.redhat.lsp4j.mcp.server.LspMcpServer; +import com.redhat.lsp4j.mcp.server.McpLanguageClientWrapper; +import com.redhat.lsp4j.mcp.server.McpLanguageServerWrapper; +import com.redhat.qute.ls.api.QuteLanguageClientAPI; +import com.redhat.qute.ls.api.QuteLanguageServerAPI; +import com.redhat.qute.ls.commons.ParentProcessWatcher; + +/** + * Qute server launcher + * + */ +public class QuteServerLauncher { + + /** + * Main entry point for the server. System properties may influence the + * behavior: + *
    + * watchParentProcess: if defined and value is false then do not watch + * for the parent process otherwise if parent process is dead then stop this + * server. + *
+ *
    + * runAsync: if defined and value is true then received message are + * processed in a separate thread than the LSP4J thread. + *
+ * + * @param args + */ + public static void main(String[] args) { + QuteLanguageServer server = new QuteLanguageServer(); + + // Create MCP cache shared between wrappers + McpCache mcpCache = new McpCache(); + + // Wrap the Language Server to intercept didOpen/didClose + QuteLanguageServerAPI wrappedServer = McpLanguageServerWrapper.wrap( + QuteLanguageServerAPI.class, server, mcpCache); + + Function wrapper; + wrapper = it -> it; + if ("true".equals(System.getProperty("runAsync"))) { + wrapper = it -> msg -> CompletableFuture.runAsync(() -> it.consume(msg)); + } + if (!"false".equals(System.getProperty("watchParentProcess"))) { + wrapper = new ParentProcessWatcher(wrappedServer, wrapper); + } + Launcher launcher = createServerLauncher(wrappedServer, System.in, System.out, + Executors.newCachedThreadPool(), wrapper); + + // Wrap the client to intercept publishDiagnostics + QuteLanguageClientAPI client = (QuteLanguageClientAPI) launcher.getRemoteProxy(); + QuteLanguageClientAPI wrappedClient = McpLanguageClientWrapper.wrap( + QuteLanguageClientAPI.class, client, mcpCache); + + server.setClient(wrappedClient); + + // Start MCP server with annotation-based tools + LspMcpServer mcpServer = LspMcpServer.builder() + .serverInfo("qute-ls", "0.25.0") + .port(9339) + .registerDependency(org.eclipse.lsp4j.services.LanguageServer.class, wrappedServer) + .registerDependency(com.redhat.lsp4j.mcp.cache.McpCache.class, mcpCache) + .build(); // Auto-discovers tools via SPI + + // Start MCP server in background thread + new Thread(() -> { + try { + Thread.sleep(2000); // Wait for LS to be fully initialized + mcpServer.start(); + } catch (Exception e) { + e.printStackTrace(); + } + }, "MCP-Server-Starter").start(); + + launcher.startListening(); + } + + /** + * Create a new Launcher for a language server and an input and output stream. + * Threads are started with the given executor service. The wrapper function is + * applied to the incoming and outgoing message streams so additional message + * handling such as validation and tracing can be included. + * + * @param server - the server that receives method calls from the + * remote client + * @param in - input stream to listen for incoming messages + * @param out - output stream to send outgoing messages + * @param executorService - the executor service used to start threads + * @param wrapper - a function for plugging in additional message + * consumers + */ + public static Launcher createServerLauncher(LanguageServer server, InputStream in, OutputStream out, + ExecutorService executorService, Function wrapper) { + return new Builder().setLocalService(server).setRemoteInterface(QuteLanguageClientAPI.class) // Set + // client + // as + // Quarkus + // language + // client + .setInput(in).setOutput(out).setExecutorService(executorService).wrapMessages(wrapper).create(); + } +} diff --git a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/api/QuteLanguageServerAPI.java b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/api/QuteLanguageServerAPI.java index 4e365f74e..526c43379 100644 --- a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/api/QuteLanguageServerAPI.java +++ b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/ls/api/QuteLanguageServerAPI.java @@ -12,10 +12,10 @@ package com.redhat.qute.ls.api; import org.eclipse.lsp4j.jsonrpc.services.JsonNotification; -import org.eclipse.lsp4j.services.LanguageServer; import com.redhat.qute.commons.ProjectInfo; import com.redhat.qute.commons.datamodel.JavaDataModelChangeEvent; +import com.redhat.qute.ls.commons.ParentProcessWatcher.ProcessLanguageServer; /** * Qute language server API. @@ -23,7 +23,9 @@ * @author Angelo ZERR * */ -public interface QuteLanguageServerAPI extends LanguageServer { +public interface QuteLanguageServerAPI extends ProcessLanguageServer, QuteProjectInfoProvider, QuteJavaTypesProvider, + QuteResolvedJavaTypeProvider, QuteJavaDefinitionProvider, QuteDataModelProjectProvider, + QuteBinaryTemplateProvider, QuteJavadocProvider, QuteTemplateProvider { /** * Notification for Qute data model changed which occurs when: @@ -38,7 +40,7 @@ public interface QuteLanguageServerAPI extends LanguageServer { */ @JsonNotification("qute/dataModelChanged") void dataModelChanged(JavaDataModelChangeEvent event); - + /** * Notification received when a Qute project is added in the workspace. * diff --git a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/project/extensions/roq/data/yaml/YamlDataLoader.java b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/project/extensions/roq/data/yaml/YamlDataLoader.java index 28b2fa63a..0f3b10d00 100644 --- a/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/project/extensions/roq/data/yaml/YamlDataLoader.java +++ b/qute.ls/com.redhat.qute.ls/src/main/java/com/redhat/qute/project/extensions/roq/data/yaml/YamlDataLoader.java @@ -19,10 +19,10 @@ import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.dataformat.yaml.YAMLMapper; import com.redhat.qute.commons.JavaFieldInfo; import com.redhat.qute.commons.ResolvedJavaTypeInfo; import com.redhat.qute.project.extensions.roq.data.DataLoader; @@ -127,7 +127,7 @@ private static List extractFieldsFromObject(ObjectNode objectNode List fields = new ArrayList<>(); // Iterate over all entries in the YAML map - objectNode.fields().forEachRemaining(entry -> { + objectNode.properties().forEach(entry -> { String fieldName = entry.getKey(); // YAML key becomes field name JsonNode value = entry.getValue(); // YAML value determines type