diff --git a/.github/workflows/maven-pr-analyze.yml b/.github/workflows/maven-pr-analyze.yml index 761b54f..bcfa111 100644 --- a/.github/workflows/maven-pr-analyze.yml +++ b/.github/workflows/maven-pr-analyze.yml @@ -24,6 +24,8 @@ on: jobs: deploy: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: # Check out Git repository - uses: actions/checkout@v4 @@ -57,7 +59,7 @@ jobs: OSSRH_ARTIFACTORY_API_TOKEN: ${{ secrets.OSSRH_ARTIFACTORY_API_TOKEN }} run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=adobe_bridgeService -Dsonar.working.directory=.scannerwork -s .github/workflows/settings.xml - name: SonarQube Quality Gate check - uses: sonarsource/sonarqube-quality-gate-action@master + uses: sonarsource/sonarqube-quality-gate-action@cb3ed20f9fec62b4c3b8ad9e77656c6adaade913 # master # Force to fail step after specific time timeout-minutes: 5 env: diff --git a/.github/workflows/maven-publish-deploy.yml b/.github/workflows/maven-publish-deploy.yml index a9f77e5..f9cb80e 100644 --- a/.github/workflows/maven-publish-deploy.yml +++ b/.github/workflows/maven-publish-deploy.yml @@ -23,6 +23,8 @@ on: jobs: deploy: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: # Check out Git repository - uses: actions/checkout@v4 diff --git a/.github/workflows/maven-publish-release.yml b/.github/workflows/maven-publish-release.yml index 4e84649..b3fa628 100644 --- a/.github/workflows/maven-publish-release.yml +++ b/.github/workflows/maven-publish-release.yml @@ -20,6 +20,8 @@ on: workflow_dispatch jobs: release: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: # Check out Git repository - uses: actions/checkout@v4 diff --git a/.github/workflows/onPushSimpleTest.yml b/.github/workflows/onPushSimpleTest.yml index 58f5516..abd49eb 100644 --- a/.github/workflows/onPushSimpleTest.yml +++ b/.github/workflows/onPushSimpleTest.yml @@ -26,6 +26,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v4 @@ -46,7 +48,7 @@ jobs: echo "branch coverage = ${{ steps.jacoco.outputs.branches }}" - name: publish coverage onto codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: file: ./integroBridgeService/target/site/jacoco/jacoco.xml name: codecov diff --git a/.github/workflows/settings.xml b/.github/workflows/settings.xml index f2cceff..93962df 100644 --- a/.github/workflows/settings.xml +++ b/.github/workflows/settings.xml @@ -22,9 +22,9 @@ ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/ ossrh - https://oss.sonatype.org/content/repositories/snapshots + https://central.sonatype.com/repository/maven-snapshots/ github gpg ${env.GPG_PASSPHRASE} diff --git a/.mvn/maven.config b/.mvn/maven.config index 6857f7b..72fce39 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1,2 @@ ---settings .mvn/settings.xml \ No newline at end of file +--settings +.mvn/settings.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..076cb1c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Does + +BridgeService is a REST service that exposes Java code/libraries as HTTP endpoints, enabling any language or framework to invoke Java methods via REST. It is particularly useful in test automation — e.g., Cypress tests calling Java backend logic without rewriting it. + +## Build & Common Commands + +```bash +# Build entire project +mvn clean install + +# Run all tests +mvn test + +# Run a specific test class +mvn -Dtest=ClassName test + +# Start BridgeService locally on port 8080 (test mode) +mvn -pl integroBridgeService exec:java -Dexec.args="test" + +# Start with demo data compiled in +mvn -pl integroBridgeService exec:java -Dexec.args="test" -Ddemo.project.mode=compile + +# Add/fix license headers (required before committing new source files) +mvn license:format + +# Generate fat JAR with all dependencies +mvn package +``` + +**Quality gates that must pass before merging:** unit tests pass, no coverage decrease, SonarCloud gate green, license headers present. + +## Module Structure + +``` +parent (pom.xml) +├── integroBridgeService/ ← main service +└── bridgeService-data/ ← test data & demo classes used by the test suite +``` + +`bridgeService-data` is a dependency of the test scope in `integroBridgeService`. It provides concrete Java classes (under `com.adobe.campaign.tests.bridge.testdata.*`) that the tests call through the REST API. + +## Architecture Overview + +### Core Request Flow + +1. **`IntegroAPI`** (`service/IntegroAPI.java`) — Spark Framework HTTP layer. Three endpoints: + - `GET /test` — health check + - `GET /service-check` — external service connectivity check + - `POST /call` — main invocation endpoint (also accepts multipart for file uploads) + +2. **`BridgeServiceFactory`** — deserializes the incoming JSON payload into a `JavaCalls` object. + +3. **`JavaCalls`** — orchestrates execution. Handles call chaining, environment variable injection, timeout management (via `ExecutorService`), and Hamcrest-based assertions on results or call duration. + +4. **`CallContent`** — represents a single method call (class name, method name, arguments). Uses reflection to invoke the method. Supports instance method calls where a prior call's return value is reused as the object instance. + +5. **`IntegroBridgeClassLoader`** — custom class loader that isolates static variable state per call session to prevent cross-call interference. Three modes: + - **Automatic** — loads all accessed classes (small memory cost) + - **Semi-Manual** — loads only statically referenced classes + - **Manual** — loads only explicitly configured packages (most control) + +6. **`JavaCallResults`** — aggregates return values, call durations, and assertion outcomes, then serializes to JSON (Jackson). + +7. **`IBSPluginManager`** + `IBSDeserializerPlugin` — plugin system that allows custom deserialization of non-JSON-serializable return types. Plugins are discovered by package scan (`IBS.PLUGINS.PACKAGE` env var). + +8. **`MetaUtils`** — reflection utilities for method lookup and object property scraping. + +9. **`ErrorObject`** — standardized error response with code, message, and stack trace. Error codes: `404` (client error: bad payload, missing class, ambiguous method), `408` (timeout), `500` (server/runtime error). + +### Key Environment Variables + +| Variable | Purpose | Default | +| -------------------------------------------------------- | ---------------------------------------- | ------------- | +| `IBS.TIMEOUT.DEFAULT` | Global execution timeout (ms) | 10000 | +| `IBS.CLASSLOADER.AUTOMATIC.INTEGRITY.INJECTION` | Class loader mode | AUTO | +| `IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES` | Packages to load in static mode | — | +| `IBS.DESERIALIZATION.DEPTH.LIMIT` | Object scraping recursion depth | 1 | +| `IBS.DESERIALIZATION.DATE.FORMAT` | SimpleDateFormat pattern for date fields | — | +| `IBS.PLUGINS.PACKAGE` | Package to scan for deserializer plugins | — | +| `IBS.SECRETS.FILTER.PREFIX` | HTTP header prefix for secrets | `ibs-secret-` | +| `IBS.HEADERS.FILTER.PREFIX` | Restrict which headers enter call-chain cache | `""` (all) | +| `IBS.ENV.HEADER.PREFIX` | HTTP header prefix for env-var injection | `ibs-env-` | +| `IBS.ENVVARS.SETTER.CLASS` / `IBS.ENVVARS.SETTER.METHOD` | Custom env var injection handler | — | + +These are managed by `ConfigValueHandlerIBS.java`. + +## Testing + +- **Framework:** TestNG (`integroBridgeService/src/test/resources/testng.xml`) +- **Coverage:** JaCoCo (reports go to `target/site/jacoco/`) +- **Test REST calls:** REST-assured +- **Assertions:** Hamcrest Matchers +- **Mocking:** Mockito +- Tests live in `integroBridgeService/src/test/java/` and call real service endpoints (not mocked — the service is started in-process before tests). + +## Deployment + +- Entry point: `MainContainer.java` (accepts `"test"` arg for test mode, or SSL keystore config for production) +- Two Docker images: `DockerfileNoSSL` and `DockerfileSSL` (TLSv1.2) +- Built via `buildScripts/buildImage.sh` +- Port 8080 (test mode), 443 (SSL production) + +## Java & Dependency Notes + +- Java 11 (source/target) is required. Do not use APIs introduced after Java 11. +- Web framework: Spark Java 2.9.4 +- JSON: Jackson 2.18.x +- Logging: Log4j 2 (50MB rotation, 3GB cleanup, 10-day retention per `docs/Technical.md`) + +## Java Naming Conventions + +Apply these prefixes consistently in all new and modified Java code: + +| Prefix | Applies to | Example | +|---|---|---| +| `in_` | Method parameters | `in_serverUrl`, `in_userId` | +| `l_` | Local variables | `l_result`, `l_callContent` | +| `lt_` | Variables scoped to a loop or condition block (do not escape the block) | `lt_entry`, `lt_key` | +| *(none)* | `for` loop counters | `i`, `j` | + +## Contribution Rules + +- All new source files must have the Adobe license header (`mvn license:format` adds it). +- Coverage must not decrease. +- SonarCloud quality gate must be green. +- A signed CLA is required for external contributors. +- All features need to be documented, if they are functional they go in README, otherwise there is a dedicated document called docs/Technical.md diff --git a/README.md b/README.md index 0b2ef5f..02e713c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ from any language or framework you are in. * [Steamlining Headers](#steamlining-headers) * [Making Assertions](#making-assertions) * [Duration-Based Assertions](#duration-based-assertions) + * [Using BridgeService as an MCP Server](#using-bridgeservice-as-an-mcp-server) + * [Enabling MCP](#enabling-mcp) + * [Discovering Tools](#discovering-tools-toolslist) + * [Calling a Discovered Tool](#calling-a-discovered-tool-toolscall) + * [The java_call Fallback Tool](#the-java_call-fallback-tool) + * [MCP Limitations](#mcp-limitations) * [Error Management](#error-management) * [Contributing to the Project](#contributing-to-the-project) * [Known Errors](#known-errors) @@ -699,10 +705,42 @@ Sometimes you may want to debug the system. In these cases you can deactivate th variable `IBS.SECRETS.BLOCK.OUTPUT` to false. However, this should only be temporary, and in production it is best to keep this control. +### Environment Variables via Headers + +Headers prefixed with `ibs-env-` are injected as environment variables into the Java execution context — equivalent +to supplying them in the `environmentVariables` JSON node, but without modifying the payload. The prefix is stripped +and the remainder uppercased to form the variable name. + +Example — pass a hostname and a locale without touching the payload: + +```shell +curl --request POST \ + --url http://localhost:8080/call \ + --header 'Content-Type: application/json' \ + --header 'ibs-env-AC.UITEST.HOST: my-instance.example.com' \ + --header 'ibs-env-AC.UITEST.LANGUAGE: en_US' \ + --data '{ + "callContent": { + "result": { + "class": "com.example.MyService", + "method": "run", + "args": [] + } + } + }' +``` + +IBS injects `AC.UITEST.HOST=my-instance.example.com` and `AC.UITEST.LANGUAGE=en_US` before the call executes. + +The prefix can be changed via `IBS.ENV.HEADER.PREFIX` or set to blank to disable the feature entirely. +Env-var headers are distinct from regular headers: they are not stored in the call-chain resolution cache and +cannot be referenced in `args`. + ### Steamlining Headers By default, IBS stores all the headers when you send a call. You have the possibility to filter the headers you want to -use by setting the run-time variable `IBS.HEADERS.FILTER.PREFIX`. +use by setting the run-time variable `IBS.HEADERS.FILTER.PREFIX`. Secret headers (`ibs-secret-*`) and env-var +headers (`ibs-env-*`) are always handled by their own mechanisms and are not affected by this filter. ## Making Assertions @@ -840,6 +878,169 @@ Example: } ``` +## Using BridgeService as an MCP Server + +BridgeService can act as an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server, allowing AI agents to discover and invoke your Java methods as typed tools over HTTP. The MCP endpoint uses JSON-RPC 2.0 and is served on the **same port** as the existing REST API. + +### Enabling MCP + +Set the environment variable `IBS.MCP.ENABLED` to `true` before starting BridgeService: + +```bash +mvn exec:java -Dexec.args="test" -DIBS.MCP.ENABLED=true -DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.example.mypackage +``` + +At startup, BridgeService scans the packages listed in `IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES` and registers every **public static method** as a named MCP tool. The naming convention is `{SimpleClassName}_{methodName}`. + +The MCP endpoint is available at: +``` +POST /mcp +``` + +An MCP client begins with the standard handshake: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "clientInfo": { "name": "my-client", "version": "1.0" }, + "capabilities": {} + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "serverInfo": { "name": "bridgeService", "version": "2.11.19" }, + "capabilities": { "tools": {} } + } +} +``` + +### Discovering Tools (tools/list) + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "SimpleStaticMethods_methodAcceptingStringArgument", + "description": "Calls com.example.SimpleStaticMethods.methodAcceptingStringArgument()", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { "type": "string" } + }, + "required": ["arg0"] + } + }, + { + "name": "java_call", + "description": "Generic BridgeService call. Accepts the full /call payload including call chaining, instance methods, environment variables, and timeout.", + "inputSchema": { "..." : "..." } + } + ] + } +} +``` + +The JSON Schema for each tool is derived from the method's parameter types: + +| Java type | JSON Schema type | +|---|---| +| `String` | `string` | +| `int` / `Integer` / `long` / `Long` | `integer` | +| `double` / `Double` / `float` / `Float` | `number` | +| `boolean` / `Boolean` | `boolean` | +| `List` / array | `array` | +| anything else | `object` | + +### Calling a Discovered Tool (tools/call) + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "SimpleStaticMethods_methodAcceptingStringArgument", + "arguments": { + "arg0": "hello" + } + } +} +``` + +On success the result contains the standard BridgeService return payload serialised as text: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [{ "type": "text", "text": "{\"returnValues\":{\"result\":\"hello_Success\"},\"callDurations\":{\"result\":3}}" }], + "isError": false + } +} +``` + +If the method throws an exception or the tool name is unknown, `isError` is `true` and `content[0].text` contains the error description. The HTTP status code is always `200` for `tools/call` — errors are reported inside the MCP result, not as HTTP errors. + +### The `java_call` Fallback Tool + +A generic `java_call` tool is always included alongside the auto-discovered tools. Its `callContent` argument accepts exactly the same payload as the standard `POST /call` endpoint, making call chaining, instance methods, environment variables, and file uploads all accessible to MCP clients: + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "java_call", + "arguments": { + "callContent": { + "step1": { + "class": "com.example.MyClass", + "method": "doSomething", + "args": ["hello"] + } + }, + "environmentVariables": { + "MY_ENV_VAR": "value" + } + } + } +} +``` + +### MCP Limitations + +* Only **public static methods** are auto-discovered. Instance methods are accessible via the `java_call` fallback tool. +* Overloaded methods with the **same number of parameters** are skipped during discovery (same restriction as the `/call` endpoint). Use `java_call` to call them explicitly. +* Parameter names are exposed as `arg0`, `arg1`, … — Java reflection does not retain source-level parameter names at runtime. + ## Error Management Currently, whenever there is an error in the underlying java call we will include the orginal error message in the error diff --git a/ReleaseNotes.md b/ReleaseNotes.md index dd03b1d..ac6d9d1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,4 +1,8 @@ # Bridge Service - RELEASE NOTES +## 2.11.19 +* **New Feature** [#12 Expose BridgeService as an MCP Server](https://github.com/adobe/bridgeService/issues/12). BridgeService can now act as a Model Context Protocol (MCP) server. When `IBS.MCP.ENABLED=true`, a `POST /mcp` endpoint is registered on the existing port. It scans `IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES` at startup and exposes each public static method as a named MCP tool discoverable via `tools/list`. A generic `java_call` fallback tool is always included for call chaining and instance methods. Please refer to ["Using BridgeService as an MCP Server"](README.md#using-bridgeservice-as-an-mcp-server) in the README for full details. +* **New Environment Variable** `IBS.MCP.ENABLED`: Set to `true` to enable the MCP endpoint (default: `false`). + ## 2.11.18 * [#180 Headers not usable as environment variable](https://github.com/adobe/bridgeService/issues/180). We discovered that variable expansion of headers did not cover environment variables. diff --git a/bridgeService-data/pom.xml b/bridgeService-data/pom.xml index ab89463..c6aea76 100644 --- a/bridgeService-data/pom.xml +++ b/bridgeService-data/pom.xml @@ -20,6 +20,14 @@ true + + + com.github.therapi + therapi-runtime-javadoc-scribe + 0.15.0 + provided + org.apache.logging.log4j log4j-core @@ -44,6 +52,6 @@ com.adobe.campaign.tests.bridge parent - 2.11.19-SNAPSHOT + 3.11.0-SNAPSHOT diff --git a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/ClassWithLogger.java b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/ClassWithLogger.java index 943549b..98556e2 100644 --- a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/ClassWithLogger.java +++ b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/ClassWithLogger.java @@ -19,43 +19,38 @@ public class ClassWithLogger { protected static Logger log = LogManager.getLogger(); - - private static final List countries = Arrays.asList("AT", "AU", "CA", "CH", "DE"); private static Random randomGen = new Random(); /** - * A getter for the Language Encodings - * @return + * Returns the fixed list of ISO 3166-1 alpha-2 country codes available for testing: + * {@code AT, AU, CA, CH, DE}. * - - public static LanguageEncodings getLanguageEncoding(){ - return languageEncoding; - } + * @return immutable list of country codes */ public static List getCountries() { return countries; } - - + /** + * Returns the shared {@link Random} generator used by this class. + * + * @return the shared {@link Random} instance + */ public static Random getRandomGen() { return randomGen; } - /** - * This method returns a random country ISOA2 code. - * - * @return ISOA2 country code + * Returns a randomly selected ISO 3166-1 alpha-2 country code from the available set + * ({@code AT, AU, CA, CH, DE}). * - * @author lepolles + * @return a random country code */ public static String fetchRandomCountry() { int l_countryNr = countries.size(); - return countries.get(getRandomGen().nextInt(l_countryNr)); } diff --git a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/EnvironmentVariableHandler.java b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/EnvironmentVariableHandler.java index cd6c157..33855ed 100644 --- a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/EnvironmentVariableHandler.java +++ b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/EnvironmentVariableHandler.java @@ -21,4 +21,5 @@ public static void setIntegroCache(Properties in_properties) { public static String getCacheProperty(String in_string) { return cache.getProperty(in_string); } + } diff --git a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/SimpleStaticMethods.java b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/SimpleStaticMethods.java index 8e31ef9..c2235b3 100644 --- a/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/SimpleStaticMethods.java +++ b/bridgeService-data/src/main/java/com/adobe/campaign/tests/bridge/testdata/one/SimpleStaticMethods.java @@ -10,7 +10,6 @@ import java.io.File; import java.io.IOException; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Arrays; @@ -22,55 +21,129 @@ public class SimpleStaticMethods { public static final String SUCCESS_VAL = "_Success"; + /** + * Returns the success string constant used for testing. + * + * @return the string {@value #SUCCESS_VAL} + */ public static String methodReturningString() { return SUCCESS_VAL; } + /** + * Returns a fixed list of four test strings. + * + * @return {@code ["NA1", "NA2", "NA3", "NA4"]} + */ public static List methodReturningList() { return Arrays.asList("NA1", "NA2", "NA3", "NA4"); } - //This method returns a class with a method called get. This was causing issue #63 + /** + * Returns a {@link ClassWithGet} instance. + * Used to test scraping of objects that have a method named {@code get}. + * + * @return a new {@link ClassWithGet} + */ public static ClassWithGet returnClassWithGet() { - return new ClassWithGet(); + return new ClassWithGet(); } - + /** + * Returns a fixed map containing two string entries. + * + * @return a map with keys {@code "object1"} and {@code "object3"} mapped to + * {@code "value1"} and {@code "value3"} respectively + */ public static Map methodReturningMap() { Map mapOfString = new HashMap(); mapOfString.put("object1", "value1"); mapOfString.put("object3", "value3"); - return mapOfString; + return mapOfString; } + /** + * Appends the success suffix to the given string. + * + * @param in_stringArgument the input string + * @return {@code in_stringArgument + "_Success"} + */ public static String methodAcceptingStringArgument(String in_stringArgument) { return in_stringArgument + SUCCESS_VAL; } + /** + * Returns the given integer multiplied by three. + * + * @param in_intArgument the input integer + * @return {@code in_intArgument * 3} + */ public static int methodAcceptingIntArgument(int in_intArgument) { - return in_intArgument *3; + return in_intArgument * 3; } + /** + * Concatenates two strings with a {@code +} separator and appends the success suffix. + * + * @param in_stringArgument1 the first string + * @param in_stringArgument2 the second string + * @return {@code in_stringArgument1 + "+" + in_stringArgument2 + "_Success"} + */ public static String methodAcceptingTwoArguments(String in_stringArgument1, String in_stringArgument2) { return in_stringArgument1 + "+" + in_stringArgument2 + SUCCESS_VAL; } + /** + * Returns the number of elements in the given list. + * + * @param in_ListArgument the input list + * @return the size of {@code in_ListArgument} + */ public static int methodAcceptingListArguments(List in_ListArgument) { return in_ListArgument.size(); } + /** + * Returns the length of the given string array. + * + * @param in_arrayArgument the input array + * @return the length of {@code in_arrayArgument} + */ public static int methodAcceptingArrayArguments(String[] in_arrayArgument) { return in_arrayArgument.length; } + + /** + * Always throws an {@link IllegalArgumentException}. + * Used to test error handling in the bridge layer. + * + * @return never returns normally + * @throws IllegalArgumentException unconditionally + */ public static String methodThrowsException() { throw new IllegalArgumentException("Will always throw this"); } + /** + * Delegates to {@link #methodThrowsException()} and propagates the exception. + * Used to test nested exception handling. + * + * @return never returns normally + * @throws IllegalArgumentException always + */ public static String methodCallingMethodThrowingException() { return methodThrowsException(); } + /** + * Reads the environment variables {@code ENVVAR1} and {@code ENVVAR2} from the + * IBS environment variable cache and returns them joined with an underscore. + * Requires those variables to have been set via {@code environmentVariables} in the + * call payload before this method is invoked. + * + * @return {@code ENVVAR1 + "_" + ENVVAR2} + */ public static String usesEnvironmentVariables() { return EnvironmentVariableHandler.getCacheProperty("ENVVAR1") + "_" + EnvironmentVariableHandler.getCacheProperty("ENVVAR2"); @@ -91,15 +164,26 @@ public static String complexMethodAcceptor(Instantiable in_arg) { return SUCCESS_VAL; } - //Exceptions - //DateAndTimeTools.convertStringToDate + /** + * Throws an {@link IllegalArgumentException} if the two integer values are equal. + * Used to test conditional exception handling. + * + * @param in_value1 first value + * @param in_value2 second value + * @throws IllegalArgumentException if {@code in_value1 == in_value2} + */ public static void methodThrowingException(int in_value1, int in_value2) { - if (in_value1==in_value2) { + if (in_value1 == in_value2) { throw new IllegalArgumentException("We do not allow numbers that are equal."); } } - //Timeouts + /** + * Sleeps for the given number of milliseconds. + * Used to test timeout enforcement in the bridge layer. + * + * @param in_sleepDuration sleep duration in milliseconds + */ public static void methodWithTimeOut(long in_sleepDuration) { try { Thread.sleep(in_sleepDuration); @@ -121,13 +205,20 @@ public static void methodCallingMethodThrowingExceptionAndPackingIt() { } } + /** + * Reads and returns the full text content of the given file using UTF-8 encoding. + * + * @param fileObject the file to read + * @return the file contents as a string + * @throws IOException if the file cannot be read + */ public static String methodAcceptingFile(File fileObject) throws IOException { return Files.readString(fileObject.toPath(), StandardCharsets.UTF_8); } //Issue #176 public int methodAcceptingStringAndArray(String stringObject, String[] arrayObject) { - return stringObject.length()+arrayObject.length; + return stringObject.length() + arrayObject.length; } } diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..1ec2f58 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,1110 @@ +# Testing and Using BridgeService as an MCP Server + +This document explains how to run BridgeService as an MCP server and verify it works, first +using the built-in demo data, then from an external project that hosts its own Java library. + +## Table of Contents + +- [Testing with the built-in demo data (bridgeService-data)](#testing-with-the-built-in-demo-data) + - [Starting the server](#starting-the-server) + - [MCP handshake](#mcp-handshake) + - [Discovering tools](#discovering-tools) + - [Calling tools](#calling-tools) + - [How the method catalog works](#how-the-method-catalog-works) + - [Call chaining best practice](#call-chaining-best-practice) + - [Project-specific pre-call setup (IBS.MCP.PRECHAIN)](#project-specific-pre-call-setup-ibsmcpprechain) + - [Built-in diagnostics tool (ibs_diagnostics)](#built-in-diagnostics-tool-ibs_diagnostics) + - [Passing secrets and environment variables in MCP client config](#passing-secrets-and-environment-variables-in-mcp-client-config) + - [Passing environment variables via headers (ibs-env-*)](#passing-environment-variables-via-headers-ibs-env-) + - [Tools that are intentionally excluded](#tools-that-are-intentionally-excluded) +- [Exposing your own project as MCP tools](#exposing-your-own-project-as-mcp-tools) + - [Injection model — adding IBS to your project](#injection-model--adding-ibs-to-your-project) + - [Aggregator model — adding your project to IBS](#aggregator-model--adding-your-project-to-ibs) + - [Configuring tool discovery](#configuring-tool-discovery) + - [Surfacing Javadoc as tool descriptions](#surfacing-javadoc-as-tool-descriptions) + - [Javadoc quality gate](#javadoc-quality-gate) + - [What tools will be generated](#what-tools-will-be-generated) +- [MCP Configuration Reference](#mcp-configuration-reference) +- [Connecting to Claude Code](#connecting-to-claude-code) + - [Naming your MCP server](#naming-your-mcp-server) + - [Start BridgeService](#start-bridgeservice) + - [Register the MCP server](#register-the-mcp-server) + - [Verify the connection](#verify-the-connection) + - [Connecting from Cursor](#connecting-from-cursor) + - [Other MCP clients](#other-mcp-clients) +- [Best Practices](#best-practices) + - [Javadoc is your tool description — garbage in, garbage out](#javadoc-is-your-tool-description--garbage-in-garbage-out) + +--- + +## MCP Configuration Reference + +| Variable | Default | Description | +|---|---|---| +| `IBS.MCP.ENABLED` | `false` | Enables the MCP endpoint at `/mcp`. Must be `true` for any MCP usage. | +| `IBS.MCP.PRECHAIN` | — | JSON `callContent` fragment prepended to every `java_call` invocation. Used for server-wide setup such as shared authentication. Can also be supplied per-client via the `ibs-prechain` HTTP header (env var takes precedence). | +| `IBS.MCP.REQUIRE_JAVADOC` | `true` | When `true`, only methods with a non-empty Javadoc comment are included in the tool catalog. Methods without Javadoc are silently excluded from `tools/list`. | + +See the relevant sections below for full configuration details and examples. + +--- + +## Testing with the built-in demo data + +`bridgeService-data` is a module included in this repository that provides concrete Java classes +used by the test suite. It is the quickest way to verify that the MCP endpoint is working correctly +without any external dependencies. + +### Starting the server + +Run the following command from the repository root. It starts IBS on port 8080 in demo mode +(which compiles `bridgeService-data` into the classpath), enables the MCP endpoint, and points +tool discovery at the `testdata.one` package: + +```bash +mvn -pl integroBridgeService exec:java \ + -Dexec.args="test" \ + -Ddemo.project.mode=compile \ + -DIBS.MCP.ENABLED=true \ + -DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.adobe.campaign.tests.bridge.testdata.one +``` + +To scan multiple packages, separate them with commas: + +```bash +-DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.adobe.campaign.tests.bridge.testdata.one,com.adobe.campaign.tests.bridge.testdata.two +``` + +### MCP handshake + +Every MCP session begins with an `initialize` request. Send it once before any other call: + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "clientInfo": { "name": "my-client", "version": "1.0" }, + "capabilities": {} + } + }' +``` + +Expected response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "serverInfo": { "name": "bridgeService", "version": "2.11.19" }, + "capabilities": { "tools": {} } + } +} +``` + +### Discovering tools + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +`tools/list` always returns exactly **one tool — `java_call`**. Its `description` contains a +catalog of all methods discovered from the configured packages. AI agents read that catalog to +learn which class and method names to place in their `callContent` payloads. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "java_call", + "description": "Generic BridgeService call. Accepts the full /call payload including call chaining, instance methods, environment variables, and timeout. Bundle all operations into one callContent chain so they share a single isolated execution context. State (including authentication) does not persist between separate tool calls.\n\nDiscovered methods (use class/method values in callContent for java_call):\n\nSimpleStaticMethods_methodReturningString\n class: com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\n method: methodReturningString\n Returns the success string constant used for testing.\n args: (none)\n\nSimpleStaticMethods_methodAcceptingStringArgument\n class: com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\n method: methodAcceptingStringArgument\n Appends the success suffix to the given string.\n arg0 (string): the input string\n\n... (further entries for ClassWithLogger methods etc.)", + "inputSchema": { + "type": "object", + "required": ["callContent"], + "properties": { + "callContent": { + "type": "object", + "description": "Map of call IDs to call definitions.", + "additionalProperties": { + "type": "object", + "required": ["class", "method"], + "properties": { + "class": { "type": "string" }, + "method": { "type": "string" }, + "args": { "type": "array" } + } + } + }, + "environmentVariables": { "type": "object" }, + "timeout": { "type": "integer" } + } + } + } + ] + } +} +``` + +### Calling tools + +All calls go through `java_call`. Use the class and method names from the catalog in +`callContent`. + +**No-argument method:** + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "java_call", + "arguments": { + "callContent": { + "result": { + "class": "com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods", + "method": "methodReturningString", + "args": [] + } + } + } + } + }' +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "{\"returnValues\":{\"result\":\"_Success\"},\"callDurations\":{\"result\":2}}" + } + ], + "isError": false + } +} +``` + +**Method with a String argument:** + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "java_call", + "arguments": { + "callContent": { + "result": { + "class": "com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods", + "method": "methodAcceptingStringArgument", + "args": ["hello"] + } + } + } + } + }' +``` + +**Call chaining** (get a country list, then pass it to another method): + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "java_call", + "arguments": { + "callContent": { + "countries": { + "class": "com.adobe.campaign.tests.bridge.testdata.one.ClassWithLogger", + "method": "getCountries", + "args": [] + }, + "size": { + "class": "com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods", + "method": "methodAcceptingListArguments", + "args": ["countries"] + } + } + } + } + }' +``` + +### How the method catalog works + +`tools/list` returns a single tool — `java_call`. Auto-discovery does not produce separately +callable tools; instead it builds a **catalog** that is embedded in the `java_call` description. + +When an AI agent calls `tools/list` it reads the catalog to learn which class and method names +exist, then constructs the appropriate `callContent` payload and calls `java_call`. The catalog is +rebuilt every time the server starts, so it stays in sync with the Java library automatically. + +**Why not separate tools per method?** + +Separate tools per method force the AI to make one HTTP round-trip per method call and lose +execution context between calls. With `java_call`, any number of steps can be bundled into one +request inside a single isolated class loader — enabling call chaining, where the return value +of step N is passed directly to step N+1 as a live Java object (not serialized JSON). This is +essential for scenarios involving authentication, object creation, or anything with mutable state. + +The catalog format in the description for each entry is: + +``` +ClassName_methodName + class: com.example.package.ClassName + method: methodName + + arg0 (type): + arg1 (type): +``` + +**Project skills and `CLAUDE.md`** can reference catalog entries by class/method name to give the +AI more context about when and how to use each one. For per-user auth or multi-step flows, the +skill prepends an auth step to the `java_call` callContent chain. + +--- + +### Call chaining best practice + +Each `java_call` invocation runs inside a freshly isolated class loader context. Static variables +set in one call are **not visible** to the next call — authentication state, cached connections, +or any other static state established in one invocation will be gone by the time a second +invocation starts. + +**Bundle related operations into a single `java_call`** using call chaining: all entries in +`callContent` share the same isolated context and execute in insertion order. The return value of +an earlier step is referenced by key in the `args` of a later step: + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "java_call", + "arguments": { + "callContent": { + "step1": { + "class": "com.example.Auth", + "method": "login", + "args": ["user", "password"] + }, + "step2": { + "class": "com.example.Resource", + "method": "create", + "args": ["step1"] + } + } + } + } + }' +``` + +All entries in `callContent` execute in the same isolated context in insertion order. A prior +call's return value is substituted by referencing its key as a string argument (e.g. `"step1"` in +the `args` of `step2`). + +The `java_call` tool description also makes this explicit, so AI agents reading the tool list +will see the guidance directly. + +--- + +### Project-specific pre-call setup (`IBS.MCP.PRECHAIN`) + +Some projects need one or more setup operations to run before every tool invocation — for example, +an authentication step that establishes a session token in the class loader's static cache. + +`IBS.MCP.PRECHAIN` addresses this at the server level. Set it to a JSON `callContent` fragment and +BridgeService will prepend those calls to every `java_call` invocation, running them inside the +same isolated context as the actual call. Pre-chain return values are stripped from the response +before it is returned. + +#### Configuration + +``` +IBS.MCP.PRECHAIN={"":{"class":"...","method":"...","args":[...]}, ...} +``` + +The value is a standard BridgeService `callContent` JSON object — the same format used in +`java_call` payloads. Entries execute in insertion order, and call-chaining dependency resolution +(referencing a prior entry's key in an `args` array) works as normal. + +Alternatively, the same JSON can be supplied as the `ibs-prechain` HTTP header on the MCP server +registration. The header is used when `IBS.MCP.PRECHAIN` is not set. This is useful for +client-side configuration in MCP clients that support custom headers (e.g. Claude Code's +`.claude.json`): + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-prechain": "{\"ibs_auth\":{\"class\":\"utils.CampaignUtils\",\"method\":\"setCurrentAuthenticationToLocal\",\"args\":[\"ibs-secret-url\",\"ibs-secret-login\",\"ibs-secret-pass\"]}}" + } + } + } +} +``` + +#### Example: CampaignTests authentication + +CampaignTests requires two steps before any operation: + +1. Fetch an auth token (`ConnectionToken.fetchAuthFromIMSBearerToken`) +2. Store it as the current authentication (`CampaignUtils.setCurrentAuthentication`) + +With `IBS.MCP.PRECHAIN` the auth is injected automatically into every `java_call` invocation: + +``` +IBS.MCP.PRECHAIN={"ibs_auth":{"class":"com.example.ConnectionToken","method":"fetchAuthFromIMSBearerToken","args":["ibs-secret-endpoint","ibs-secret-token"]},"ibs_set_auth":{"class":"com.example.CampaignUtils","method":"setCurrentAuthentication","args":["ibs_auth"]}} +``` + +#### Passing secrets securely + +Sensitive values (tokens, endpoints) should be passed as HTTP headers in the MCP server +registration, using the existing `ibs-secret-` prefix. BridgeService injects these headers into +the class loader result cache at the start of every call, making them available for +call-chaining dependency resolution — no extra code is required. + +Register the server with credentials in the `.mcp.json` `headers` map: + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-secret-endpoint": "https://my-instance.campaign.adobe.com", + "ibs-secret-token": "eyJ..." + } + } + } +} +``` + +The prechain args reference those header names as plain strings: + +``` +IBS.MCP.PRECHAIN={"ibs_auth":{"class":"...","method":"fetchAuthFromIMSBearerToken","args":["ibs-secret-endpoint","ibs-secret-token"]}, ...} +``` + +BridgeService resolves the strings `"ibs-secret-endpoint"` and `"ibs-secret-token"` to the +corresponding header values via the standard dependency mechanism — exactly as call chaining would +resolve any prior result by key. + +**Secrets are always protected:** + +- Headers with the `ibs-secret-` prefix are suppressed from all tool responses. +- Pre-chain return values (e.g. `ibs_auth`, `ibs_set_auth`) are stripped from `returnValues` + before the response is returned — only the actual tool result is visible to the caller. +- The value of `IBS.MCP.PRECHAIN` is never written to logs at INFO or DEBUG level. + +#### How prechain integrates with your call chain + +The user's `callContent` steps execute after the prechain steps inside the same isolated context. +User steps can reference prechain keys by name in their `args` and receive the return values by +reference (same JVM heap). Prechain keys are stripped from `returnValues` and `callDurations` +before the response is returned. + +```json +{ + "callContent": { + "result": { + "class": "com.example.Resource", + "method": "create", + "args": ["ibs_auth"] + } + } +} +``` + +Here `"ibs_auth"` is the key of a prechain step that returned an `Authentication` object. It is +resolved at runtime — it is never passed as a literal string. + +#### PRECHAIN is deployment-wide — not suitable for per-user auth + +`IBS.MCP.PRECHAIN` is a server environment variable. It is the same for every user connecting to +that deployment. This makes it the right mechanism for setup that is **uniform across all callers** +— classloader configuration, plugin initialisation, or auth that uses a shared service account. + +**It is the wrong mechanism for per-user auth.** On a shared IBS deployment two testers may need +to connect to different Campaign instances, use different auth methods (local session vs IMS +bearer), or supply different credentials. A single PRECHAIN value cannot satisfy both. + +Per-user auth belongs in the **project skill** — a `CLAUDE.md` or a +`~/.claude/skills/.md` file that each user maintains locally. The skill instructs the AI +to open every `java_call` chain with the auth step appropriate for that user: + +```markdown +## BridgeService MCP usage +Always start every java_call chain with your auth step: + + "ibs_auth": { + "class": "utils.CampaignUtils", + "method": "setCurrentAuthenticationToLocal", + "args": ["https://my-instance.campaign.adobe.com", "myuser", "mypassword"] + } +``` + +User A's skill might call `setCurrentAuthenticationToLocal`; User B's might call +`fetchAuthFromIMSBearerToken`. Both connect to the same IBS server with no server-side changes. + +Credentials in skills should reference `ibs-secret-*` headers (configured in the user's +`.mcp.json`) rather than be written in plain text: + +```markdown + "args": ["ibs-secret-endpoint", "ibs-secret-token"] +``` + +**Summary: what belongs where** + +| Concern | Right place | Why | +|---|---|---| +| Per-user auth credentials and method | User's skill / `CLAUDE.md` | Differs across callers — cannot be a server-side default | +| Shared service-account auth | `IBS.MCP.PRECHAIN` | Truly uniform across all users | +| Classloader / plugin setup | `IBS.MCP.PRECHAIN` | Deployment-wide, same for everyone | +| Class/method names and call patterns | Skill / `CLAUDE.md` | LLM guidance, not execution | + +#### Consumer project guidance + +Any project registering BridgeService as an MCP server that requires setup before every tool call +should: + +1. Configure `IBS.MCP.PRECHAIN` with the required setup steps. +2. Pass credentials as `ibs-secret-*` headers in the MCP server registration. +3. Document the pattern in the project's own `CLAUDE.md` so AI agents understand they do not + need to perform auth themselves — it is handled transparently by the server: + +```markdown +## BridgeService MCP usage +- Auth is pre-configured via IBS.MCP.PRECHAIN; do not include auth calls in your tool payloads. +- For multi-step scenarios use `java_call` with call chaining (single `callContent` payload). + State does not persist between separate tool calls. +``` + +--- + +### Built-in diagnostics tool (`ibs_diagnostics`) + +BridgeService exposes a built-in `ibs_diagnostics` tool alongside `java_call`. It requires no +arguments and has no dependency on the HOST project — it is always available regardless of whether +tool discovery succeeds. + +Call it via `tools/call`: + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": "ibs_diagnostics", "arguments": {} } + }' +``` + +Response: + +```json +{ + "ibsVersion": "3.11.0", + "deploymentMode": "TEST", + "mcpConfig": { + "packagesConfigured": "com.example.services", + "prechainActive": true, + "javadocRequired": true + }, + "headers": { + "secretHeaderKeys": ["ibs-secret-login", "ibs-secret-pass", "ibs-secret-url"], + "envVarHeaders": { + "AC.UITEST.HOST": "my-instance.example.com", + "AC.UITEST.LANGUAGE": "en_US" + }, + "regularHeaderCount": 3 + }, + "discoveredToolCount": 142 +} +``` + +**Fields:** + +| Field | Description | +|---|---| +| `ibsVersion` | Running BridgeService version | +| `deploymentMode` | `TEST` or `PRODUCTION` | +| `mcpConfig.packagesConfigured` | Value of `IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES` | +| `mcpConfig.prechainActive` | Whether a prechain is configured (env var or header) | +| `mcpConfig.javadocRequired` | Whether `IBS.MCP.REQUIRE_JAVADOC` is enabled | +| `headers.secretHeaderKeys` | Names of `ibs-secret-*` headers received (values suppressed) | +| `headers.envVarHeaders` | Decoded env-var headers (`ibs-env-*` prefix stripped, uppercased) | +| `headers.regularHeaderCount` | Count of headers that are neither secret nor env-var | +| `discoveredToolCount` | Number of methods in the `java_call` catalog | + +Use `ibs_diagnostics` as the first step when connecting a new HOSTSERVICE — it confirms +connectivity, verifies that all `ibs-secret-*` and `ibs-env-*` headers are reaching the server, +and shows whether PRECHAIN and Javadoc requirements are active, all without touching HOST code. + +--- + +### Passing secrets and environment variables in MCP client config + +BridgeService reads two special header prefixes from every MCP request. Both are configured in +the `headers` block of the MCP server registration in your client config file (`.claude.json`, +`.cursor/mcp.json`, etc.). + +#### Secrets (`ibs-secret-*`) + +Headers prefixed with `ibs-secret-` are injected into BridgeService's arg-resolution cache. +They can be referenced by name as plain strings in `java_call` `args` arrays — BridgeService +substitutes the header name with its value at runtime. Secret values are **never** included in +tool responses or logs. + +Use them for credentials that your Java methods need as arguments (URLs, usernames, passwords, +tokens): + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-secret-url": "https://my-instance.example.com/nl/jsp/soaprouter.jsp", + "ibs-secret-login": "admin", + "ibs-secret-pass": "mypassword" + } + } + } +} +``` + +A `java_call` step then references them by header name: + +```json +{ + "callContent": { + "auth": { + "class": "utils.CampaignUtils", + "method": "setCurrentAuthenticationToLocal", + "args": ["ibs-secret-url", "ibs-secret-login", "ibs-secret-pass"] + } + } +} +``` + +#### Environment variables (`ibs-env-*`) + +Headers prefixed with `ibs-env-` are injected as environment variables into the Java execution +context — equivalent to supplying them in the `environmentVariables` JSON node of a `/call` +payload. The prefix is stripped and the remainder uppercased to form the variable name. + +Use them for configuration values your Java methods read from the environment (hostnames, ports, +locale, feature flags): + +```json +{ + "headers": { + "ibs-env-AC.UITEST.HOST": "my-instance.example.com", + "ibs-env-AC.UITEST.LANGUAGE": "en_US", + "ibs-env-AC.UITEST.MAILING.PORT": "143" + } +} +``` + +> **The `ibs-env-` prefix is stripped before injection.** The header +> `ibs-env-AC.UITEST.HOST` becomes the environment variable `AC.UITEST.HOST` — not +> `IBS-ENV-AC.UITEST.HOST`. Your Java code must read the name **without** the prefix. +> This is a common source of confusion: the header name and the variable name are different. + +#### Key difference + +| Header prefix | Where the value lands | Referenceable in `args`? | +|---|---|---| +| `ibs-secret-*` | Arg-resolution cache | **Yes** — use the full header name as the arg string | +| `ibs-env-*` | Java env / IntegroCache | **No** — passing the key as an arg sends it as a literal string | + +#### Full registration example + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-secret-url": "https://my-instance.example.com/nl/jsp/soaprouter.jsp", + "ibs-secret-login": "admin", + "ibs-secret-pass": "mypassword", + "ibs-env-AC.UITEST.HOST": "my-instance.example.com", + "ibs-env-AC.UITEST.LANGUAGE": "en_US", + "ibs-env-AC.UITEST.MAILING.PORT": "143", + "ibs-prechain": "{\"ibs_auth\":{\"class\":\"utils.CampaignUtils\",\"method\":\"setCurrentAuthenticationToLocal\",\"args\":[\"ibs-secret-url\",\"ibs-secret-login\",\"ibs-secret-pass\"]}}" + } + } + } +} +``` + +Use `ibs_diagnostics` to verify that all headers are reaching the server correctly — it reports +secret key names, decoded env-var key/value pairs, and regular header count without exposing +secret values. + +--- + +### Passing environment variables via headers (`ibs-env-*`) + +Some Java methods depend on environment variables that must be set before execution — for example, +a hostname, a port, or a locale that changes per deployment. Both the `/call` and `/mcp` endpoints +accept these as HTTP headers with the `ibs-env-` prefix, as an alternative to the +`environmentVariables` JSON node. + +BridgeService reads every request header whose name begins with `ibs-env-`, strips the prefix, +uppercases the remainder, and injects the key/value pair as an environment variable into the +`JavaCalls` execution context — exactly as if it had been provided in the `environmentVariables` +node of a `/call` payload. Header-supplied variables are merged with any variables already in the +payload; payload variables take precedence for the same key. + +This works for the REST `/call` endpoint, auto-discovered MCP tools, and the `java_call` fallback. + +#### Configuring env vars in `.claude.json` + +Add env vars to the `headers` block of the MCP server registration, using the `ibs-env-` prefix: + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-secret-login": "admin", + "ibs-secret-pass": "mypassword", + "ibs-env-AC.UITEST.HOST": "accintg-ci93.rd.campaign.adobe.com", + "ibs-env-AC.UITEST.LANGUAGE": "en_US", + "ibs-env-AC.UITEST.MAILING.PORT": "143", + "ibs-env-AC.UITEST.MAILING.HOST": "mail.example.com" + } + } + } +} +``` + +At runtime the server extracts these headers and injects: + +| Environment variable | Value | +| ------------------------- | ---------------------------------- | +| `AC.UITEST.HOST` | `accintg-ci93.rd.campaign.adobe.com` | +| `AC.UITEST.LANGUAGE` | `en_US` | +| `AC.UITEST.MAILING.PORT` | `143` | +| `AC.UITEST.MAILING.HOST` | `mail.example.com` | + +#### Prefix configuration + +The default prefix is `ibs-env-`. It can be changed via: + +``` +IBS.ENV.HEADER.PREFIX=my-custom-prefix- +``` + +Set it to blank to disable the feature entirely: + +``` +IBS.ENV.HEADER.PREFIX= +``` + +#### Interaction with `IBS.MCP.PRECHAIN` + +Env vars injected from `ibs-env-*` headers are populated into `JavaCalls.environmentVariables` +inside `addHeaders()`, which is called before `submitCalls()`. Pre-chain steps that depend on +env vars (e.g., a hostname resolution step) will therefore see them. + +--- + +### Methods intentionally excluded from the catalog + +Some methods in `SimpleStaticMethods` are not included in the auto-discovery catalog: + +| Method | Reason excluded | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `overLoadedMethod1Arg(String)` and `overLoadedMethod1Arg(int)` | Both have one parameter — ambiguous, cannot be disambiguated by parameter count | +| `methodAcceptingFile(File)` | `File` parameters require multi-part upload, not representable as a JSON arg | +| Any instance method | Only `public static` methods are discovered | + +These methods are still fully accessible via `java_call` — simply specify the class and method +name directly in the `callContent` payload. + +--- + +## Exposing your own project as MCP tools + +There are two ways to deploy IBS with your project, matching the two models described in the main +README. + +### Injection model — adding IBS to your project + +This is the recommended approach. You add `integroBridgeService` as a dependency to your project +and start the server from within it. + +**1. Add the dependency to your `pom.xml`:** + +```xml + + com.adobe.campaign.tests.bridge.service + integroBridgeService + 2.11.19 + +``` + +**2. Start the service** with MCP enabled and your package(s) listed: + +```bash +mvn compile exec:java \ + -Dexec.mainClass=MainContainer \ + -Dexec.args="test" \ + -DIBS.MCP.ENABLED=true \ + -DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.example.myproject.services +``` + +All public static methods found in `com.example.myproject.services` (and its sub-packages) are +immediately available as MCP tools. + +### Aggregator model — adding your project to IBS + +In this model you clone (or fork) the BridgeService repository, add your project as a Maven +dependency inside `integroBridgeService/pom.xml`, and build a fat JAR or Docker image that +bundles everything together. + +```xml + + + com.example + myproject + 1.0.0 + +``` + +Then build and start: + +```bash +mvn clean package +java -jar integroBridgeService/target/integroBridgeService-*.jar test \ + -DIBS.MCP.ENABLED=true \ + -DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.example.myproject.services +``` + +### Configuring tool discovery + +The environment variable `IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES` controls which packages are +scanned at startup. It accepts a comma-separated list of package prefixes: + +``` +IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.example.services,com.example.utils +``` + +Only **public static methods** in classes directly under those packages (and sub-packages) are +registered as tools. The same variable also drives the IBS class loader isolation, so every class +your methods transitively depend on must be reachable under one of the listed prefixes — or in +the system classpath. + +### Surfacing Javadoc as tool descriptions + +By default, tool descriptions fall back to a generated string (`"Calls com.example.MyClass.methodName()"`). +To have the actual Javadoc comment appear as the tool description in `tools/list`, add the +`therapi-runtime-javadoc-scribe` annotation processor to your project's `pom.xml`: + +```xml + + com.github.therapi + therapi-runtime-javadoc-scribe + 0.15.0 + provided + +``` + +**No extra Maven goals are required.** The annotation processor runs automatically during the +`compile` phase, which is already part of your existing `mvn clean package`. It embeds the +Javadoc comments as resource files inside your compiled JAR +(e.g. `javadoc/com/example/EmailService.json`). + +At startup, `MCPToolDiscovery` reads those embedded resources via `RuntimeJavadoc.getJavadoc(method)`. +As long as your JAR is on BridgeService's classpath when the server starts, the descriptions are +picked up transparently — no configuration needed beyond the dependency above. + +`@param` Javadoc tags are also read and used as the parameter descriptions in the tool's +`inputSchema`, helping the AI understand what each argument expects. + +Without the dependency, tools are still fully functional; only the description quality is reduced. + +### Javadoc quality gate + +By default (`IBS.MCP.REQUIRE_JAVADOC=true`), BridgeService **only exposes methods that have a +non-empty Javadoc comment**. Methods without Javadoc are silently skipped at startup and will not +appear in `tools/list`. + +This is intentional. A method with no Javadoc would receive a generic fallback description such as +`"Calls com.example.MyClass.method()"`, which gives an AI agent no useful information about when +or why to call it. Exposing such tools increases the risk of accidental invocations. + +**To opt out** (expose all public static methods regardless of documentation): + +``` +IBS.MCP.REQUIRE_JAVADOC=false +``` + +**Writing good Javadoc for MCP tools** goes beyond just satisfying the gate. Descriptions should +make the testing or domain purpose self-evident so an AI agent can distinguish your tools from +others in a multi-server session: + +| Weak | Better | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `/** Returns a list of countries. */` | `/** Returns the fixed list of ISO 3166-1 country codes (AT, AU, CA, CH, DE) used as test fixtures for campaign validation. */` | +| `/** Sends an email. */` | `/** Sends a test email via the configured SMTP mock and returns the delivery receipt ID. */` | +| `/** Gets the cache value. */` | `/** Returns the value stored under the given key in the in-process integro test-execution cache. */` | + +Include `@param` tags for each argument — BridgeService uses them to populate the parameter +descriptions in the tool's `inputSchema`. + +### What tools will be generated + +Given a project with the following class (with `therapi-runtime-javadoc-scribe` on the classpath): + +```java +package com.example.myproject.services; + +public class EmailService { + /** Sends an email to the given recipient with the specified subject. */ + public static String sendEmail(String recipient, String subject) { ... } + /** Returns the list of message subjects in the given account's inbox. */ + public static List listInbox(String account) { ... } + /** Deletes all messages in the given account's inbox. */ + public static void purgeInbox(String account) { ... } + public String getStatus() { ... } // instance method — excluded +} +``` + +IBS would embed the following catalog entries in the `java_call` description, with descriptions +sourced from Javadoc: + +| Catalog entry | Description | Args | +| ------------------------- | ------------------------------------------------------------------ | ------------------------------ | +| `EmailService_sendEmail` | Sends an email to the given recipient with the specified subject. | `arg0: string`, `arg1: string` | +| `EmailService_listInbox` | Returns the list of message subjects in the given account's inbox. | `arg0: string` | +| `EmailService_purgeInbox` | Deletes all messages in the given account's inbox. | `arg0: string` | + +Without `therapi-runtime-javadoc-scribe`, the descriptions would fall back to +`"Calls com.example.myproject.services.EmailService.sendEmail()"` etc. + +`getStatus()` is excluded because it is an instance method. + +An AI agent calling `tools/list` reads the catalog, then invokes methods via `java_call` by +placing the listed class and method values in a `callContent` entry. For instance methods, +overloaded methods, or call chaining across multiple steps, the same `java_call` payload handles +all cases — see the [Making Java Calls](../README.md#making-java-calls) section of the main README. + +--- + +## Connecting to Claude Code + +Claude Code supports MCP servers over HTTP natively. Once BridgeService is running with MCP +enabled, you can register it as a named MCP server and Claude Code will automatically call +`tools/list` at startup and make the tools available during your session. + +### Naming your MCP server + +BridgeService is generic infrastructure that can be deployed for many different projects. The name +you give it at registration time becomes the namespace for all its tools in MCP clients — +Claude Code, for example, exposes tools as `mcp____ClassName_method`. + +**Always name the server after your project**, not `"bridgeService"`. This makes tools +self-contextualising in a multi-server session: + +| Registration name | Tool name seen by agent | +| ------------------------------------ | ---------------------------------------------- | +| `bridgeService` (generic, avoid) | `mcp__bridgeService__EmailService_sendEmail` | +| `CampaignTests` (project-specific) | `mcp__CampaignTests__EmailService_sendEmail` | +| `EmailAutomation` (feature-specific) | `mcp__EmailAutomation__EmailService_sendEmail` | + +The name is set entirely on the client side when you register the server — no BridgeService +configuration is needed. See [Register the MCP server](#register-the-mcp-server) for the +exact command. + +### Start BridgeService + +Using the demo data (quickest way to try it): + +```bash +mvn -pl integroBridgeService exec:java \ + -Dexec.args="test" \ + -Ddemo.project.mode=compile \ + -DIBS.MCP.ENABLED=true \ + -DIBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES=com.adobe.campaign.tests.bridge.testdata.one +``` + +For your own project, start BridgeService with your packages configured instead (see +[Exposing your own project as MCP tools](#exposing-your-own-project-as-mcp-tools)). + +### Register the MCP server + +In a separate terminal, register the running BridgeService instance as an MCP server in +Claude Code. Replace `CampaignTests` with the name of your project (see +[Naming your MCP server](#naming-your-mcp-server)). Use `--scope user` to make it available +globally across all projects, or omit it to register it only for the current project: + +```bash +# Register globally (available in all Claude Code sessions) +claude mcp add --transport http CampaignTests http://localhost:8080/mcp --scope user + +# Register for the current project only +claude mcp add --transport http CampaignTests http://localhost:8080/mcp +``` + +This writes an entry to `~/.claude/mcp.json` (global) or `.mcp.json` in the project root +(project-scoped). The resulting config entry looks like: + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +If BridgeService is deployed remotely (Docker/K8s), replace `http://localhost:8080` with the +actual deployment URL. + +### Verify the connection + +List all registered MCP servers and their status: + +```bash +claude mcp list +``` + +You should see `bridgeService` listed as connected. You can also check inside an interactive +Claude Code session with the `/mcp` slash command. + +Once connected, Claude Code will discover the `java_call` tool and its method catalog via +`tools/list` at the start of each session. You can then ask Claude to call your Java methods — +for example: + +> "Use java_call to call `SimpleStaticMethods.methodAcceptingStringArgument` with the argument `hello`" + +To remove the server registration when you no longer need it: + +```bash +claude mcp remove bridgeService +``` + +### Connecting from Cursor + +Cursor reads MCP server configuration from `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` +in the project root. The format is identical to Claude Code's config. Add an entry manually: + +```json +{ + "mcpServers": { + "CampaignTests": { + "type": "http", + "url": "http://localhost:8080/mcp", + "headers": { + "ibs-secret-login": "admin", + "ibs-secret-pass": "mypassword", + "ibs-secret-url": "https://my-instance.example.com/nl/jsp/soaprouter.jsp", + "ibs-prechain": "{\"ibs_auth\":{\"class\":\"utils.CampaignUtils\",\"method\":\"setCurrentAuthenticationToLocal\",\"args\":[\"ibs-secret-url\",\"ibs-secret-login\",\"ibs-secret-pass\"]}}" + } + } + } +} +``` + +Restart Cursor after editing the file. Cursor will call `tools/list` at session start and the +`java_call` catalog will be available to the AI. + +### Other MCP clients + +Any MCP client that supports the `2024-11-05` protocol version over HTTP can connect to +BridgeService. The registration format varies by client but the required values are always the +same: + +| Field | Value | +|---|---| +| Transport | HTTP (stateless JSON-RPC over POST) | +| URL | `http://:8080/mcp` | +| Headers | `ibs-secret-*` for credentials, `ibs-env-*` for env vars, `ibs-prechain` for per-client prechain | + +Consult your client's documentation for where to place the config file and how to pass custom +HTTP headers. + +--- + +## Best Practices + +### Javadoc is your tool description — garbage in, garbage out + +The AI agent sees exactly what you write in Javadoc. Nothing more, nothing less. + +BridgeService embeds Javadoc comments into the `java_call` catalog at startup. When an AI reads +`tools/list`, the catalog is its only source of truth about what each method does, what its +parameters mean, and when to call it. A vague or missing description produces a vague or wrong +tool invocation. + +**Treat every Javadoc comment as a prompt you are writing for the AI.** + +| Weak | Why it fails | Better | +|---|---|---| +| `/** Creates a recipient. */` | No context — the AI cannot tell when or why | `/** Creates a randomly generated test recipient in the nms:recipient schema and returns its internal ID. */` | +| `/** @param auth the auth */` | Circular — adds no information | `/** @param auth Authentication object returned by setCurrentAuthenticationToLocal */` | +| `/** Sends email. */` | Too generic — ambiguous in a multi-tool session | `/** Sends the prepared delivery to all recipients in its target list and returns the delivery log ID. */` | +| No `@param` tags | AI has to guess argument purpose and order | One `@param` per argument, describing what value is expected | + +**What to include in every exposed method's Javadoc:** + +1. **What the method does** — in domain terms, not implementation terms. +2. **What it returns** — the type and meaning of the return value. +3. **What each parameter expects** — use `@param` tags; BridgeService uses them as argument descriptions in the tool schema. +4. **When to use it vs similar methods** — if overloads or related methods exist, say which scenario each is for. + +**The quality gate enforces the minimum bar.** `IBS.MCP.REQUIRE_JAVADOC=true` (the default) +silently drops any method with no Javadoc from the catalog entirely — it will not appear in +`tools/list` and cannot be called via auto-discovery. Passing the gate (a non-empty comment) +is necessary but not sufficient: a one-word description passes the gate but still produces a +useless tool entry. + +**Good Javadoc pays compound interest.** A well-described method is discovered correctly the +first time, requires no follow-up prompting, and stays reliable as the AI session context +grows. Poor descriptions lead to incorrect calls, wasted round-trips, and subtle bugs that are +hard to trace back to a missing `@param`. diff --git a/integroBridgeService/pom.xml b/integroBridgeService/pom.xml index 4fe9d6d..b308af5 100644 --- a/integroBridgeService/pom.xml +++ b/integroBridgeService/pom.xml @@ -20,6 +20,12 @@ test + + + src/main/resources + true + + org.apache.maven.plugins @@ -167,11 +173,18 @@ reflections 0.10.2 + + + com.github.therapi + therapi-runtime-javadoc + 0.15.0 + com.adobe.campaign.tests.bridge parent - 2.11.19-SNAPSHOT + 3.11.0-SNAPSHOT diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/BridgeServiceFactory.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/BridgeServiceFactory.java index d2da424..8bc2e07 100644 --- a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/BridgeServiceFactory.java +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/BridgeServiceFactory.java @@ -8,7 +8,7 @@ */ package com.adobe.campaign.tests.bridge.service; -import com.adobe.campaign.tests.bridge.service.exceptions.IBSPayloadException; +import com.adobe.campaign.tests.bridge.service.exceptions.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -17,6 +17,17 @@ import java.util.Set; public class BridgeServiceFactory { + + public static final String ERROR_CALL_TIMEOUT = "The call you made exceeds the set timeout limit."; + public static final String ERROR_IBS_INTERNAL = "Internal IBS error. Please file a bug report with the project and provide this JSON in the report."; + public static final String ERROR_PAYLOAD_INCONSISTENCY = "We detected an inconsistency in your payload."; + static final String ERROR_JSON_TRANSFORMATION = "JSON Transformation issue : Problem processing request. The given json could not be mapped to a Java Call"; + static final String ERROR_CALLING_JAVA_METHOD = "Error during call of target Java Class and Method."; + static final String ERROR_JAVA_OBJECT_NOT_FOUND = "Could not find the given class or method."; + static final String ERROR_IBS_CONFIG = "The provided class and method for setting environment variables is not valid."; + static final String ERROR_IBS_RUNTIME = "Problems with payload."; + static final String ERROR_AMBIGUOUS_METHOD = "No unique method could be identified that matches your request."; + static final String ERROR_JAVA_OBJECT_NOT_ACCESSIBLE = "The java object you want to call is inaccessible. This is very possibly a scope problem."; /** * Creates a Java Call Object given a JSON as a String * @param in_requestJSON A JSON Object as a String @@ -101,6 +112,53 @@ public static String createExceptionPayLoad(ErrorObject in_errorObject) { return getErrorPayloadAsString(in_errorObject); } + /** + * Maps a runtime exception to a serialized error payload using the standard IBS + * exception-to-error-code mapping. Centralises the mapping so both the REST /call + * endpoint and the MCP tools/call endpoint produce consistent error responses and + * a new exception type only needs to be added in one place. + * + * @param e the exception to map + * @return serialized JSON error payload + */ + public static String createExceptionPayLoad(Exception e) { + String title; + int code; + boolean includeStackTrace = true; + + if (e instanceof IBSTimeOutException) { + title = ERROR_CALL_TIMEOUT; + code = 408; + includeStackTrace = false; + } else if (e instanceof NonExistentJavaObjectException) { + title = ERROR_JAVA_OBJECT_NOT_FOUND; + code = 404; + includeStackTrace = false; + } else if (e instanceof AmbiguousMethodException) { + title = ERROR_AMBIGUOUS_METHOD; + code = 404; + includeStackTrace = false; + } else if (e instanceof IBSConfigurationException) { + title = ERROR_IBS_CONFIG; + code = 500; + } else if (e instanceof IBSRunTimeException) { + title = ERROR_IBS_RUNTIME; + code = 500; + } else if (e instanceof TargetJavaMethodCallException) { + title = ERROR_CALLING_JAVA_METHOD; + code = 500; + } else if (e instanceof JavaObjectInaccessibleException) { + title = ERROR_JAVA_OBJECT_NOT_ACCESSIBLE; + code = 404; + includeStackTrace = false; + } else { + title = ERROR_IBS_INTERNAL; + code = 500; + } + + return createExceptionPayLoad(new ErrorObject(e, title, code, includeStackTrace)); + } + //Calls the testable getPayloadAdString private static String getErrorPayloadAsString(ErrorObject in_errorObject) { return getErrorPayloadAsString(new ObjectMapper(), in_errorObject); diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/CallContent.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/CallContent.java index a2461f7..a557912 100644 --- a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/CallContent.java +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/CallContent.java @@ -191,7 +191,11 @@ public Object call(IntegroBridgeClassLoader iClassLoader) { } catch (InstantiationException e) { throw new NonExistentJavaObjectException( "Could not instantiate class. The given class " + this.getClassName() + " could not be found."); - } catch (NonExistentJavaObjectException | NoSuchMethodException e) { + } catch (NonExistentJavaObjectException e) { + // Re-throw as-is to preserve the specific message (e.g. type coercion failure, + // method-not-found from fetchMethodCandidates). + throw e; + } catch (NoSuchMethodException e) { throw new NonExistentJavaObjectException( "Could not find the method " + this.getFullName() + "."); } catch (LinkageError e) { @@ -269,24 +273,68 @@ public int hashCode() { * @return The transformed array of objects for execution purposes. */ Object[] castArgs(Object[] in_objects, Method in_method) { - List ltr_objects = new ArrayList<>(); - for (int i=0; i < in_objects.length; i++) { - Class lt_type = in_method.getParameterTypes()[i]; - - //If object is an array - if (lt_type.isArray() && in_objects[i] instanceof List) { - Class lt_targetClass = lt_type.getComponentType(); - Object lt_targetObject = Array.newInstance(lt_targetClass, ((List)in_objects[i]).size()); - for (int i2=0; i2 < ((List)in_objects[i]).size(); i2++) { - Array.set(lt_targetObject, i2, ((List) in_objects[i]).get(i2)); - } - ltr_objects.add(lt_targetObject); - } else { - ltr_objects.add(in_objects[i]); + List ltr_objects = new ArrayList<>(); + for (int i = 0; i < in_objects.length; i++) { + ltr_objects.add(coerceArg(in_objects[i], in_method.getParameterTypes()[i])); + } + return ltr_objects.toArray(); + } + + /** + * Coerces a single argument value to match the expected parameter type. + * + *

Handles two conversion cases: + *

+ * + *

All other values are returned unchanged; Java reflection handles the remaining + * widening/unboxing (e.g. {@code Integer} → {@code long}) transparently. + * + * @param in_value the argument value to coerce + * @param in_targetType the Java parameter type the method expects + * @return the coerced value, ready to pass to {@link java.lang.reflect.Method#invoke} + * @throws NonExistentJavaObjectException if the value is a String that cannot be parsed + * into the required numeric type (e.g. {@code "hello"} for an {@code int} parameter) + */ + Object coerceArg(Object in_value, Class in_targetType) { + // List → Array + if (in_targetType.isArray() && in_value instanceof List) { + Class lt_componentType = in_targetType.getComponentType(); + Object lt_array = Array.newInstance(lt_componentType, ((List) in_value).size()); + for (int i = 0; i < ((List) in_value).size(); i++) { + Array.set(lt_array, i, ((List) in_value).get(i)); } + return lt_array; } - return ltr_objects.toArray(); + // String → numeric/boolean primitive or boxed equivalent + if (in_value instanceof String) { + String strVal = (String) in_value; + try { + if (in_targetType == int.class || in_targetType == Integer.class) + return Integer.parseInt(strVal); + if (in_targetType == long.class || in_targetType == Long.class) + return Long.parseLong(strVal); + if (in_targetType == double.class || in_targetType == Double.class) + return Double.parseDouble(strVal); + if (in_targetType == float.class || in_targetType == Float.class) + return Float.parseFloat(strVal); + if (in_targetType == boolean.class || in_targetType == Boolean.class) + return Boolean.parseBoolean(strVal); + } catch (NumberFormatException e) { + throw new NonExistentJavaObjectException( + "Argument value \"" + strVal + "\" could not be converted to " + + in_targetType.getSimpleName() + "."); + } + } + + return in_value; } } diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/ConfigValueHandlerIBS.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/ConfigValueHandlerIBS.java index c2a1ebe..26919fc 100644 --- a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/ConfigValueHandlerIBS.java +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/ConfigValueHandlerIBS.java @@ -8,7 +8,9 @@ */ package com.adobe.campaign.tests.bridge.service; +import java.io.InputStream; import java.util.Arrays; +import java.util.Properties; public enum ConfigValueHandlerIBS { DEPLOYMENT_MODEL("IBS.DEPLOYMENT.MODEL", "TEST", false, @@ -33,7 +35,7 @@ public enum ConfigValueHandlerIBS { "When set, we use the given method to store the static execution variables."), STATIC_INTEGRITY_PACKAGES("IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES", "", false, "This parameter is used for flagging the packages that are to to be used by the IBS class loader. When used, the static variables are not stored between java calls."), - PRODUCT_VERSION("IBS.PRODUCT.VERSION", "2.11.16", false, + PRODUCT_VERSION("IBS.PRODUCT.VERSION", readVersion(), false, "The version of the BridgeService, which is used to identify the version that is accessed."), PRODUCT_USER_VERSION("IBS.PRODUCT.USER.VERSION", "not set", false, "The version of the BridgeService, which is used to identify the version that is accessed."), @@ -68,7 +70,25 @@ public void activate(String in_value) { PLUGINS_PACKAGE( "IBS.PLUGINS.PACKAGE", null, false, "The package path in which IBS should search for the plugins."), DESERIALIZATION_DATE_FORMAT( - "IBS.DESERIALIZATION.DATE.FORMAT", "NONE", false, "The date format to be used for deserialization."); + "IBS.DESERIALIZATION.DATE.FORMAT", "NONE", false, "The date format to be used for deserialization."), + MCP_ENABLED("IBS.MCP.ENABLED", "false", false, + "When set to true, enables the MCP server endpoint at POST /mcp, exposing configured packages as tools."), + MCP_REQUIRE_JAVADOC("IBS.MCP.REQUIRE_JAVADOC", "true", false, + "When true (default), only methods with a non-empty Javadoc comment are exposed as MCP tools. " + + "Methods without Javadoc are silently skipped. Set to false to expose all public static methods."), + MCP_PRECHAIN("IBS.MCP.PRECHAIN", null, false, + "JSON callContent fragment prepended to every auto-discovered MCP tool invocation. " + + "Entries execute in the same isolated context as the actual call, so call-chaining " + + "dependencies work normally. Argument strings matching request header names (including " + + "ibs-secret-* headers) are resolved to their header values via the standard dependency " + + "mechanism. Pre-chain return values are stripped from the response. " + + "Use for project-specific setup such as authentication."), + ENV_HEADER_PREFIX("IBS.ENV.HEADER.PREFIX", "ibs-env-", false, + "HTTP headers whose name starts with this prefix are extracted and injected as " + + "environment variables into every call (REST /call and MCP tools/call). The prefix " + + "is stripped and the remainder uppercased to obtain the variable name. For example, " + + "a header named 'ibs-env-AC.UITEST.HOST' with value 'example.com' sets the " + + "environment variable 'AC.UITEST.HOST=example.com'. Set to blank to disable."); public final String systemName; public final String defaultValue; @@ -144,4 +164,19 @@ public boolean is(String... in_values) { return Arrays.stream(in_values).anyMatch(this::is); } + + static String readVersion() { + try (InputStream is = ConfigValueHandlerIBS.class.getResourceAsStream("/bridge-service.properties")) { + if (is != null) { + Properties props = new Properties(); + props.load(is); + String version = props.getProperty("version"); + if (version != null && !version.isEmpty()) { + return version; + } + } + } catch (Exception ignored) { + } + return "unknown"; + } } diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/IntegroAPI.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/IntegroAPI.java index 5be74f4..2fde221 100644 --- a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/IntegroAPI.java +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/IntegroAPI.java @@ -29,26 +29,17 @@ import java.util.Map; import java.util.stream.Collectors; +import static com.adobe.campaign.tests.bridge.service.BridgeServiceFactory.*; import static spark.Spark.*; public class IntegroAPI { - public static final String ERROR_CALL_TIMEOUT = "The call you made exceeds the set timeout limit."; public static final String ERROR_CONTENT_TYPE = "application/problem+json"; public static final String SYSTEM_UP_MESSAGE = "All systems up"; - public static final String ERROR_IBS_INTERNAL = "Internal IBS error. Please file a bug report with the project and provide this JSON in the report."; - public static final String ERROR_PAYLOAD_INCONSISTENCY = "We detected an inconsistency in your payload."; public static final String UPLOADED_FILE_REF = "uploaded_file"; public static final String JAVA_CALL_REF = "call_part"; - protected static final String ERROR_JSON_TRANSFORMATION = "JSON Transformation issue : Problem processing request. The given json could not be mapped to a Java Call"; - protected static final String ERROR_CALLING_JAVA_METHOD = "Error during call of target Java Class and Method."; - protected static final String ERROR_JAVA_OBJECT_NOT_FOUND = "Could not find the given class or method."; - protected static final String ERROR_IBS_CONFIG = "The provided class and method for setting environment variables is not valid."; - protected static final String ERROR_IBS_RUNTIME = "Problems with payload."; - protected static final String ERROR_AMBIGUOUS_METHOD = "No unique method could be identified that matches your request."; - protected static final String ERROR_JAVA_OBJECT_NOT_ACCESSIBLE = "The java object you want to call is inaccessible. This is very possibly a scope problem."; - private static final Logger log = LogManager.getLogger(); public static final String ERROR_BAD_MULTI_PART_REQUEST = "When sending a multi-part request, you need to at least have a payload for the callContent."; public static final String STD_UPLOAD_DIR = "upload"; + private static final Logger log = LogManager.getLogger(); public static void startServices(int port) { @@ -148,6 +139,16 @@ public static void startServices(int port) { fetchedFromJSON.fetchSecrets()); }); + if (ConfigValueHandlerIBS.MCP_ENABLED.is("true")) { + MCPRequestHandler mcpHandler = new MCPRequestHandler(); + post("/mcp", mcpHandler::handle); + log.info("MCP endpoint enabled at POST /mcp"); + get("/.well-known/oauth-authorization-server", (req, res) -> { + res.status(404); + return "{\"error\":\"not_found\",\"error_description\":\"This server does not support OAuth\"}"; + }); + } + after((req, res) -> { res.type("application/json"); }); diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/JavaCalls.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/JavaCalls.java index 6238ccd..3b6e159 100644 --- a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/JavaCalls.java +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/JavaCalls.java @@ -208,26 +208,60 @@ public void setAssertions(Map assertions) { } /** - * Adds headers to the results cache of the ClassLoader. We throw an exception o the header corresponds to a - * callContent + * Processes incoming request headers into three strictly disjoint namespaces: + * + *

    + *
  1. Secret headers ({@code IBS.SECRETS.FILTER.PREFIX}, default {@code ibs-secret-}) — stored in + * the classloader result cache and marked as secrets so their values are suppressed from the response. + * They can be referenced by key in {@code args} for call-chain dependency resolution.
  2. + *
  3. Env-var headers ({@code IBS.ENV.HEADER.PREFIX}, default {@code ibs-env-}) — injected directly + * into {@code environmentVariables} as Java execution env vars. The prefix is stripped and the + * remainder uppercased to form the variable name (e.g. {@code ibs-env-AC.HOST} → {@code AC.HOST}). + * These headers are not added to the classloader cache and cannot be used as call-chain args.
  4. + *
  5. Regular headers (matching {@code IBS.HEADERS.FILTER.PREFIX}, default {@code ""} = all) — stored + * in the classloader result cache and can be referenced by key in {@code args}. Secret and env-var + * headers are excluded from this group even when the filter prefix would otherwise match.
  6. + *
+ * + * An {@link com.adobe.campaign.tests.bridge.service.exceptions.IBSPayloadException} is thrown if any header + * key collides with a {@code callContent} entry name. * * @param in_mapOHeaders A map containing header values coming from the request */ public void addHeaders(Map in_mapOHeaders) { LogManagement.logStep(LogManagement.STD_STEPS.STORE_HEADERS); + + String envPrefix = ConfigValueHandlerIBS.ENV_HEADER_PREFIX.fetchValue(); + boolean envPrefixActive = envPrefix != null && !envPrefix.isBlank(); + String lowerEnvPrefix = envPrefixActive ? envPrefix.toLowerCase(java.util.Locale.ROOT) : ""; + + // Regular headers → classloader cache for call-chain dependency resolution. + // Secrets and env-var headers are handled separately and excluded here. in_mapOHeaders.keySet().stream() - .filter(i -> (i.startsWith(ConfigValueHandlerIBS.HEADERS_FILTER_PREFIX.fetchValue()) && !i.startsWith( - ConfigValueHandlerIBS.SECRETS_FILTER_PREFIX.fetchValue()))).forEach(fk -> { + .filter(i -> i.startsWith(ConfigValueHandlerIBS.HEADERS_FILTER_PREFIX.fetchValue()) + && !i.startsWith(ConfigValueHandlerIBS.SECRETS_FILTER_PREFIX.fetchValue()) + && !(envPrefixActive && i.toLowerCase(java.util.Locale.ROOT).startsWith(lowerEnvPrefix))) + .forEach(fk -> { this.getLocalClassLoader().getCallResultCache().put(fk, in_mapOHeaders.get(fk)); this.getLocalClassLoader().getHeaderSet().add(fk); }); + // Secret headers → classloader secret cache (suppressed from output, resolvable in args). in_mapOHeaders.keySet().stream() .filter(i -> i.startsWith(ConfigValueHandlerIBS.SECRETS_FILTER_PREFIX.fetchValue())).forEach(fk -> { this.getLocalClassLoader().getCallResultCache().put(fk, in_mapOHeaders.get(fk)); this.getLocalClassLoader().getSecretSet().add(fk); }); + // Env-var headers → environment variables only (not added to the call-chain cache). + if (envPrefixActive) { + in_mapOHeaders.entrySet().stream() + .filter(e -> e.getKey().toLowerCase(java.util.Locale.ROOT).startsWith(lowerEnvPrefix)) + .forEach(e -> this.environmentVariables.setProperty( + e.getKey().substring(envPrefix.length()).toUpperCase(java.util.Locale.ROOT), + e.getValue())); + } + //Check for duplicates between headers and call contents if (this.getLocalClassLoader().getHeaderSet().stream() .anyMatch(s -> this.getCallContent().keySet().contains(s))) { diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPRequestHandler.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPRequestHandler.java new file mode 100644 index 0000000..7a6596d --- /dev/null +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPRequestHandler.java @@ -0,0 +1,456 @@ +/* + * Copyright 2022 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ +package com.adobe.campaign.tests.bridge.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import spark.Request; +import spark.Response; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Handles JSON-RPC 2.0 requests arriving at the POST /mcp endpoint. + * + * Implements the MCP (Model Context Protocol) Streamable HTTP transport for tool access: + * - initialize : MCP handshake + * - tools/list : returns a single {@code java_call} tool whose description embeds a + * catalog of auto-discovered methods + * - tools/call : invokes the {@code java_call} tool, which accepts arbitrary BridgeService + * call chains; auto-discovered methods are not directly callable as + * separate MCP tools — the catalog in the description tells the LLM which + * class and method names to place in the callContent payload + * + * Tool discovery is performed once at construction time using IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES. + */ +public class MCPRequestHandler { + + private static final Logger log = LogManager.getLogger(); + private static final String JSONRPC_VERSION = "2.0"; + private static final String MCP_PROTOCOL_VERSION = "2024-11-05"; + private static final String JAVA_CALL_TOOL_NAME = "java_call"; + private static final String DIAGNOSTICS_TOOL_NAME = "ibs_diagnostics"; + static final String PRECHAIN_HEADER = "ibs-prechain"; + + private static final String JAVA_CALL_TOOL_SCHEMA = "{" + + "\"type\":\"object\"," + + "\"required\":[\"callContent\"]," + + "\"properties\":{" + + "\"callContent\":{" + + "\"type\":\"object\"," + + "\"description\":\"Map of call IDs to call definitions. A string arg matching a prior call ID is substituted with that call's result.\"," + + "\"additionalProperties\":{" + + "\"type\":\"object\"," + + "\"required\":[\"class\",\"method\"]," + + "\"properties\":{" + + "\"class\":{\"type\":\"string\",\"description\":\"Fully qualified Java class name\"}," + + "\"method\":{\"type\":\"string\",\"description\":\"Method name\"}," + + "\"args\":{\"type\":\"array\",\"description\":\"Method arguments\"}," + + "\"returnType\":{\"type\":\"string\",\"description\":\"Optional expected return type\"}" + + "}}}," + + "\"environmentVariables\":{\"type\":\"object\",\"description\":\"Key-value pairs injected before execution\"}," + + "\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in milliseconds (0=unlimited, default 10000)\"}" + + "}}"; + + private static final String DIAGNOSTICS_TOOL_SCHEMA = "{" + + "\"type\":\"object\"," + + "\"properties\":{}," + + "\"required\":[]" + + "}"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final List> toolList; + private final int discoveredToolCount; + + /** + * Constructs the handler, performs tool discovery from IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES, + * and builds a single {@code java_call} tool whose description embeds a catalog of all + * discovered methods. + */ + public MCPRequestHandler() { + MCPToolDiscovery.DiscoveryResult discovery = MCPToolDiscovery.discoverTools( + ConfigValueHandlerIBS.STATIC_INTEGRITY_PACKAGES.fetchValue()); + String catalog = buildCatalog(discovery.tools, discovery.methodRegistry); + this.discoveredToolCount = discovery.methodRegistry.size(); + + List> tools = new ArrayList<>(); + try { + Map javaCallTool = new LinkedHashMap<>(); + javaCallTool.put("name", JAVA_CALL_TOOL_NAME); + javaCallTool.put("description", buildJavaCallDescription(catalog)); + javaCallTool.put("inputSchema", mapper.readValue(JAVA_CALL_TOOL_SCHEMA, Map.class)); + tools.add(javaCallTool); + + Map diagnosticsTool = new LinkedHashMap<>(); + diagnosticsTool.put("name", DIAGNOSTICS_TOOL_NAME); + diagnosticsTool.put("description", + "Built-in IBS diagnostic tool. Returns IBS version, MCP config state, " + + "and header classification: secret key names (values suppressed), " + + "env-var key+value pairs (decoded: prefix stripped, uppercased), " + + "and regular header count. No arguments required. " + + "Does not depend on HOST packages — always available."); + diagnosticsTool.put("inputSchema", mapper.readValue(DIAGNOSTICS_TOOL_SCHEMA, Map.class)); + tools.add(diagnosticsTool); + } catch (JsonProcessingException e) { + log.error("Failed to parse tool schema — one or more tools will not be available.", e); + } + this.toolList = Collections.unmodifiableList(tools); + log.info("MCPRequestHandler ready: {} method(s) in catalog via java_call.", discoveredToolCount); + } + + /** + * Spark route handler. Parses the incoming JSON-RPC 2.0 request and dispatches + * to the appropriate handler. All exceptions are caught and returned as MCP errors + * rather than propagating to Spark's HTTP exception handlers. + */ + public Object handle(Request req, Response res) { + res.type("application/json"); + + Map body; + try { + body = mapper.readValue(req.body(), Map.class); + } catch (Exception e) { + res.status(400); + return buildError(null, -32700, "Parse error: " + e.getMessage()); + } + + Object id = body.get("id"); + String method = (String) body.get("method"); + + // Notifications have no id — acknowledge with 202 and no body + if (id == null && method != null && !method.equals("initialize")) { + res.status(202); + return ""; + } + + if (method == null) { + return buildError(id, -32600, "Invalid Request: missing method field"); + } + + try { + switch (method) { + case "initialize": + return buildResult(id, buildInitializeResult()); + + case "tools/list": + return buildResult(id, Collections.singletonMap("tools", toolList)); + + case "tools/call": + @SuppressWarnings("unchecked") + Map params = (Map) body.getOrDefault("params", Collections.emptyMap()); + Map headers = req.headers().stream() + .collect(Collectors.toMap(k -> k, req::headers)); + return handleToolCall(id, params, headers); + + default: + return buildError(id, -32601, "Method not found: " + method); + } + } catch (Exception e) { + log.error("Unexpected error handling MCP method '{}': {}", method, e.getMessage(), e); + return buildError(id, -32603, "Internal error: " + e.getMessage()); + } + } + + private Map buildInitializeResult() { + Map serverInfo = new LinkedHashMap<>(); + serverInfo.put("name", "bridgeService"); + serverInfo.put("version", ConfigValueHandlerIBS.PRODUCT_VERSION.fetchValue()); + + Map capabilities = new LinkedHashMap<>(); + capabilities.put("tools", new LinkedHashMap<>()); + + Map result = new LinkedHashMap<>(); + result.put("protocolVersion", MCP_PROTOCOL_VERSION); + result.put("serverInfo", serverInfo); + result.put("capabilities", capabilities); + return result; + } + + @SuppressWarnings("unchecked") + private String handleToolCall(Object id, Map params, Map headers) { + String toolName = (String) params.get("name"); + Map arguments = (Map) params.getOrDefault("arguments", + Collections.emptyMap()); + + if (toolName == null) { + return buildError(id, -32602, "Invalid params: missing tool name"); + } + + if (JAVA_CALL_TOOL_NAME.equals(toolName)) { + return handleJavaCall(id, arguments, headers); + } + + if (DIAGNOSTICS_TOOL_NAME.equals(toolName)) { + return handleDiagnostics(id, headers); + } + + return buildCallToolResult(id, "Unknown tool: " + toolName + + ". The only executable tool is java_call — use the class and method from its description catalog.", + true); + } + + /** + * Builds the catalog text embedded in the java_call tool description. Each discovered + * method is listed with its fully qualified class name, method name, Javadoc description, + * and argument list so the LLM can construct the correct callContent payload. + */ + private String buildCatalog(List> tools, Map methodRegistry) { + if (methodRegistry.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append("Discovered methods (use class/method values in callContent for java_call):\n\n"); + for (Map.Entry entry : methodRegistry.entrySet()) { + String toolName = entry.getKey(); + Method method = entry.getValue(); + + sb.append(toolName).append("\n"); + sb.append(" class: ").append(method.getDeclaringClass().getName()).append("\n"); + sb.append(" method: ").append(method.getName()).append("\n"); + + Map toolDef = tools.stream() + .filter(t -> toolName.equals(t.get("name"))) + .findFirst() + .orElse(null); + + if (toolDef != null) { + String desc = (String) toolDef.get("description"); + if (desc != null && !desc.isEmpty()) { + sb.append(" ").append(desc).append("\n"); + } + + @SuppressWarnings("unchecked") + Map schema = (Map) toolDef.get("inputSchema"); + if (schema != null) { + @SuppressWarnings("unchecked") + Map props = (Map) schema.get("properties"); + if (props == null || props.isEmpty()) { + sb.append(" args: (none)\n"); + } else { + for (Map.Entry propEntry : props.entrySet()) { + @SuppressWarnings("unchecked") + Map propSchema = (Map) propEntry.getValue(); + String type = (String) propSchema.get("type"); + String propDesc = (String) propSchema.get("description"); + sb.append(" ").append(propEntry.getKey()) + .append(" (").append(type).append("): ") + .append(propDesc).append("\n"); + } + } + } + } + sb.append("\n"); + } + return sb.toString().trim(); + } + + /** + * Assembles the full java_call tool description, combining the base usage guidance with + * the auto-discovered method catalog (when methods are found in the configured packages). + */ + private String buildJavaCallDescription(String catalog) { + String base = "Generic BridgeService call. Accepts the full /call payload including call chaining, " + + "instance methods, environment variables, and timeout. " + + "Bundle all operations into one callContent chain so they share a single isolated " + + "execution context. State (including authentication) does not persist between " + + "separate tool calls."; + if (catalog.isEmpty()) { + return base; + } + return base + "\n\n" + catalog; + } + + /** + * Parses the IBS.MCP.PRECHAIN JSON string into an ordered map of CallContent entries. + * Returns an empty map if the value is null, blank, or malformed (logs a warning in + * the malformed case without printing the raw value to avoid leaking credentials). + * + * @param prechainJson the raw JSON string from IBS.MCP.PRECHAIN + * @return ordered map of prechain call entries, never null + */ + private Map parsePrechainJson(String prechainJson) { + if (prechainJson == null || prechainJson.isBlank()) { + return Collections.emptyMap(); + } + try { + return mapper.readValue(prechainJson, + new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.warn("IBS.MCP.PRECHAIN could not be parsed — pre-chain skipped. Check the JSON syntax."); + return Collections.emptyMap(); + } + } + + @SuppressWarnings("unchecked") + private String handleJavaCall(Object id, Map arguments, Map headers) { + try { + // MCP clients may serialise complex object arguments as JSON strings rather than + // nested objects. Unwrap callContent if it arrived as a pre-serialised string. + Object cc = arguments.get("callContent"); + if (cc instanceof String) { + arguments.put("callContent", mapper.readValue((String) cc, Object.class)); + } + // Prepend PRECHAIN entries to callContent so java_call chains share the same + // server-level setup (e.g. auth) as the rest of the catalog. PRECHAIN keys are + // stripped from the result. + String prechainJson = ConfigValueHandlerIBS.MCP_PRECHAIN.fetchValue(); + if (prechainJson == null || prechainJson.isBlank()) { + prechainJson = headers.get(PRECHAIN_HEADER); + } + Map prechain = parsePrechainJson(prechainJson); + if (!prechain.isEmpty()) { + Map callContent = (Map) arguments + .computeIfAbsent("callContent", k -> new LinkedHashMap<>()); + // Build a new map with prechain entries first, then the user's entries + Map merged = new LinkedHashMap<>(); + for (Map.Entry e : prechain.entrySet()) { + merged.put(e.getKey(), mapper.convertValue(e.getValue(), Map.class)); + } + merged.putAll(callContent); + arguments.put("callContent", merged); + } + String json = mapper.writeValueAsString(arguments); + JavaCalls calls = BridgeServiceFactory.createJavaCalls(json); + calls.addHeaders(headers); + JavaCallResults results = calls.submitCalls(); + prechain.keySet().forEach(k -> { + results.getReturnValues().remove(k); + results.getCallDurations().remove(k); + }); + String resultJson = mapper.writeValueAsString(results); + return buildCallToolResult(id, resultJson, false); + } catch (Exception e) { + log.debug("java_call tool failed: {}", e.getMessage()); + return buildCallToolResult(id, exceptionToErrorPayload(e), true); + } + } + + /** + * Handles the ibs_diagnostics tool call. Returns a diagnostic JSON payload using only + * IBS-owned config — no HOST class dependencies. Secret header values are never included; + * only their key names are reported. Env-var header keys and decoded values are included + * because they are not secrets. + * + *

This tool is designed to be called within the MCP boundary to verify connectivity, + * header reception, and MCP configuration without requiring HOST project knowledge. + */ + private String handleDiagnostics(Object id, Map headers) { + try { + Map diag = new LinkedHashMap<>(); + diag.put("ibsVersion", ConfigValueHandlerIBS.PRODUCT_VERSION.fetchValue()); + diag.put("deploymentMode", ConfigValueHandlerIBS.DEPLOYMENT_MODEL.fetchValue()); + + Map mcpConfig = new LinkedHashMap<>(); + mcpConfig.put("packagesConfigured", + ConfigValueHandlerIBS.STATIC_INTEGRITY_PACKAGES.fetchValue()); + String prechain = ConfigValueHandlerIBS.MCP_PRECHAIN.fetchValue(); + mcpConfig.put("prechainActive", prechain != null && !prechain.isBlank()); + mcpConfig.put("javadocRequired", + Boolean.parseBoolean(ConfigValueHandlerIBS.MCP_REQUIRE_JAVADOC.fetchValue())); + diag.put("mcpConfig", mcpConfig); + + String secretPrefix = ConfigValueHandlerIBS.SECRETS_FILTER_PREFIX.fetchValue(); + String envPrefix = ConfigValueHandlerIBS.ENV_HEADER_PREFIX.fetchValue(); + boolean envPrefixActive = envPrefix != null && !envPrefix.isBlank(); + String lowerEnvPrefix = envPrefixActive ? envPrefix.toLowerCase(java.util.Locale.ROOT) : ""; + + List secretKeys = headers.keySet().stream() + .filter(k -> k.startsWith(secretPrefix)) + .sorted() + .collect(Collectors.toList()); + + Map envVars = new LinkedHashMap<>(); + if (envPrefixActive) { + headers.entrySet().stream() + .filter(e -> e.getKey().toLowerCase(java.util.Locale.ROOT).startsWith(lowerEnvPrefix)) + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> envVars.put( + e.getKey().substring(envPrefix.length()).toUpperCase(java.util.Locale.ROOT), + e.getValue())); + } + String headerPrechain = headers.get(PRECHAIN_HEADER); + if (headerPrechain != null && !headerPrechain.isBlank()) { + mcpConfig.put("prechainActive", true); + } + + long regularHeaderCount = headers.keySet().stream() + .filter(k -> !k.startsWith(secretPrefix) + && !(envPrefixActive + && k.toLowerCase(java.util.Locale.ROOT).startsWith(lowerEnvPrefix))) + .count(); + + Map headerSummary = new LinkedHashMap<>(); + headerSummary.put("secretHeaderKeys", secretKeys); + headerSummary.put("envVarHeaders", envVars); + headerSummary.put("regularHeaderCount", regularHeaderCount); + diag.put("headers", headerSummary); + + diag.put("discoveredToolCount", discoveredToolCount); + return buildCallToolResult(id, mapper.writeValueAsString(diag), false); + } catch (Exception e) { + log.error("ibs_diagnostics tool failed: {}", e.getMessage(), e); + return buildCallToolResult(id, exceptionToErrorPayload(e), true); + } + } + + private String buildResult(Object id, Object result) { + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", JSONRPC_VERSION); + response.put("id", id); + response.put("result", result); + return toJson(response); + } + + private String buildError(Object id, int code, String message) { + Map error = new LinkedHashMap<>(); + error.put("code", code); + error.put("message", message); + + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", JSONRPC_VERSION); + response.put("id", id); + response.put("error", error); + return toJson(response); + } + + private String buildCallToolResult(Object id, String text, boolean isError) { + Map content = new LinkedHashMap<>(); + content.put("type", "text"); + content.put("text", text); + + Map result = new LinkedHashMap<>(); + result.put("content", Collections.singletonList(content)); + result.put("isError", isError); + + return buildResult(id, result); + } + + /** + * Converts an exception into a serialized ErrorObject payload, mirroring the error + * structure returned by the /call endpoint so MCP clients receive the same level of + * detail (originalException, originalMessage, failureAtStep, stackTrace, etc.). + */ + private String exceptionToErrorPayload(Exception e) { + return BridgeServiceFactory.createExceptionPayLoad(e); + } + + private String toJson(Object obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("Failed to serialise MCP response", e); + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal serialisation error\"}}"; + } + } +} diff --git a/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscovery.java b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscovery.java new file mode 100644 index 0000000..c95b760 --- /dev/null +++ b/integroBridgeService/src/main/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscovery.java @@ -0,0 +1,250 @@ +/* + * Copyright 2022 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ +package com.adobe.campaign.tests.bridge.service; + +import com.github.therapi.runtimejavadoc.CommentFormatter; +import com.github.therapi.runtimejavadoc.MethodJavadoc; +import com.github.therapi.runtimejavadoc.ParamJavadoc; +import com.github.therapi.runtimejavadoc.RuntimeJavadoc; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.reflections.Reflections; +import org.reflections.scanners.Scanners; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Discovers Java methods in configured packages and converts them into MCP tool definitions. + * Only public static methods are exposed. Overloaded methods with the same parameter count + * are skipped (they are accessible via the generic java_call tool instead). + */ +public class MCPToolDiscovery { + + private static final Logger log = LogManager.getLogger(); + private static final CommentFormatter COMMENT_FORMATTER = new CommentFormatter(); + + /** + * Holds the results of a tool discovery scan. + */ + public static class DiscoveryResult { + /** MCP tool definitions ready for serialisation in a tools/list response. */ + public final List> tools; + /** Maps each tool name to the Java Method it represents, used to build the catalog in the java_call description. */ + public final Map methodRegistry; + + public DiscoveryResult(List> tools, Map methodRegistry) { + this.tools = Collections.unmodifiableList(tools); + this.methodRegistry = Collections.unmodifiableMap(methodRegistry); + } + } + + /** + * Scans the given comma-separated package prefixes and builds MCP tool definitions for + * every discoverable public static method. + * + * @param packagesCsv the value of IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES + * @return a DiscoveryResult containing the tool list and dispatch registry + */ + public static DiscoveryResult discoverTools(String packagesCsv) { + List> tools = new ArrayList<>(); + Map registry = new LinkedHashMap<>(); + + if (packagesCsv == null || packagesCsv.trim().isEmpty()) { + log.warn("IBS.CLASSLOADER.STATIC.INTEGRITY.PACKAGES is not set — no tools will be discovered for MCP. " + + "Set this property to enable tool discovery."); + return new DiscoveryResult(tools, registry); + } + + // Strip trailing dots that IBS uses as package separators (e.g. "com.example.") + String[] packages = Arrays.stream(packagesCsv.split(",")) + .map(String::trim) + .map(p -> p.endsWith(".") ? p.substring(0, p.length() - 1) : p) + .filter(p -> !p.isEmpty()) + .toArray(String[]::new); + + Set> allClasses = new LinkedHashSet<>(); + for (String pkg : packages) { + try { + Reflections reflections = new Reflections(pkg, Scanners.SubTypes.filterResultsBy(s -> true)); + allClasses.addAll(reflections.getSubTypesOf(Object.class)); + } catch (Exception e) { + log.warn("Failed to scan package '{}' for MCP tools: {}", pkg, e.getMessage()); + } + } + + for (Class clazz : allClasses) { + // Only inspect methods declared directly on this class — not inherited statics + Map> byName = Arrays.stream(clazz.getMethods()) + .filter(m -> m.getDeclaringClass().equals(clazz)) + .filter(m -> Modifier.isStatic(m.getModifiers())) + .collect(Collectors.groupingBy(Method::getName)); + + for (Map.Entry> entry : byName.entrySet()) { + String methodName = entry.getKey(); + List overloads = entry.getValue(); + + if (overloads.size() == 1) { + // Unique method name on this class — use simple tool name + Method method = overloads.get(0); + if (ConfigValueHandlerIBS.MCP_REQUIRE_JAVADOC.is("true") && !hasJavadoc(method)) { + log.debug("Skipping {}.{} — no Javadoc (IBS.MCP.REQUIRE_JAVADOC=true)", + clazz.getSimpleName(), methodName); + } else { + String toolName = clazz.getSimpleName() + "_" + methodName; + registerTool(tools, registry, toolName, method); + } + } else { + // Multiple overloads — disambiguate by parameter count + Map> byParamCount = overloads.stream() + .collect(Collectors.groupingBy(Method::getParameterCount)); + + for (Map.Entry> countEntry : byParamCount.entrySet()) { + if (countEntry.getValue().size() > 1) { + log.warn("Skipping ambiguous overloads for {}.{}({} param(s)) — " + + "use the java_call tool to invoke them directly.", + clazz.getName(), methodName, countEntry.getKey()); + } else { + Method method = countEntry.getValue().get(0); + if (ConfigValueHandlerIBS.MCP_REQUIRE_JAVADOC.is("true") && !hasJavadoc(method)) { + log.debug("Skipping {}.{} — no Javadoc (IBS.MCP.REQUIRE_JAVADOC=true)", + clazz.getSimpleName(), methodName); + } else { + String toolName = clazz.getSimpleName() + "_" + methodName + "_" + countEntry.getKey(); + registerTool(tools, registry, toolName, method); + } + } + } + } + } + } + + log.info("MCP tool discovery complete: {} tool(s) registered from {} class(es).", + tools.size(), allClasses.size()); + return new DiscoveryResult(tools, registry); + } + + private static void registerTool(List> tools, Map registry, + String toolName, Method method) { + if (registry.containsKey(toolName)) { + log.warn("Tool name collision for '{}' — skipping duplicate from {}. " + + "Consider using fully qualified class names.", + toolName, method.getDeclaringClass().getName()); + return; + } + Map tool = new LinkedHashMap<>(); + tool.put("name", toolName); + tool.put("description", buildDescription(method)); + tool.put("inputSchema", buildInputSchema(method)); + + tools.add(tool); + registry.put(toolName, method); + log.debug("Registered MCP tool '{}'", toolName); + } + + /** + * Returns the tool description for a method: the Javadoc comment if available, + * otherwise a generated fallback of the form "Calls FullClassName.methodName()". + */ + private static String buildDescription(Method method) { + try { + MethodJavadoc javadoc = RuntimeJavadoc.getJavadoc(method); + if (javadoc != null) { + String comment = COMMENT_FORMATTER.format(javadoc.getComment()); + if (!comment.isEmpty()) { + return comment; + } + } + } catch (Exception e) { + log.debug("Could not read Javadoc for {}.{}: {}", + method.getDeclaringClass().getSimpleName(), method.getName(), e.getMessage()); + } + return "Calls " + method.getDeclaringClass().getName() + "." + method.getName() + "()"; + } + + /** + * Returns true if the method has a non-empty Javadoc comment available at runtime. + */ + static boolean hasJavadoc(Method method) { + try { + MethodJavadoc javadoc = RuntimeJavadoc.getJavadoc(method); + return javadoc != null && !COMMENT_FORMATTER.format(javadoc.getComment()).isEmpty(); + } catch (Exception e) { + return false; + } + } + + /** + * Builds a JSON Schema object describing the input parameters of a method. + * Parameter names are generated as arg0, arg1, ... since Java reflection + * does not expose source-level parameter names unless compiled with -parameters. + * Parameter descriptions are taken from {@code @param} Javadoc tags when available. + */ + static Map buildInputSchema(Method method) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length == 0) { + schema.put("properties", new LinkedHashMap<>()); + return schema; + } + + List paramDocs = Collections.emptyList(); + try { + MethodJavadoc javadoc = RuntimeJavadoc.getJavadoc(method); + if (javadoc != null) { + paramDocs = javadoc.getParams(); + } + } catch (Exception e) { + log.debug("Could not read param Javadoc for {}.{}: {}", + method.getDeclaringClass().getSimpleName(), method.getName(), e.getMessage()); + } + + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (int i = 0; i < paramTypes.length; i++) { + String paramName = "arg" + i; + Map paramSchema = new LinkedHashMap<>(); + paramSchema.put("type", javaTypeToJsonSchemaType(paramTypes[i])); + + String paramDesc = paramTypes[i].getSimpleName(); + if (i < paramDocs.size()) { + String paramComment = COMMENT_FORMATTER.format(paramDocs.get(i).getComment()); + if (!paramComment.isEmpty()) { + paramDesc = paramComment; + } + } + paramSchema.put("description", paramDesc); + properties.put(paramName, paramSchema); + required.add(paramName); + } + + schema.put("properties", properties); + schema.put("required", required); + return schema; + } + + /** + * Maps a Java type to its closest JSON Schema primitive type. + */ + static String javaTypeToJsonSchemaType(Class type) { + if (type == String.class) return "string"; + if (type == int.class || type == Integer.class + || type == long.class || type == Long.class) return "integer"; + if (type == double.class || type == Double.class + || type == float.class || type == Float.class) return "number"; + if (type == boolean.class || type == Boolean.class) return "boolean"; + if (type.isArray() || List.class.isAssignableFrom(type)) return "array"; + return "object"; + } +} diff --git a/integroBridgeService/src/main/resources/bridge-service.properties b/integroBridgeService/src/main/resources/bridge-service.properties new file mode 100644 index 0000000..defbd48 --- /dev/null +++ b/integroBridgeService/src/main/resources/bridge-service.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ERemoteTests.java b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ERemoteTests.java index 18e3977..878c09c 100644 --- a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ERemoteTests.java +++ b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ERemoteTests.java @@ -56,7 +56,7 @@ public void testErrors() { JavaCallResults jcr = new JavaCallResults(); given().body(jcr).post(EndPointURL + "call").then().statusCode(404).and().assertThat() - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JSON_TRANSFORMATION)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JSON_TRANSFORMATION)) .body("detail", Matchers.startsWith( "Unrecognized field \"callDurations\" (class com.adobe.campaign.tests.bridge.service.JavaCalls), not marked as ignorable")) .body("code", Matchers.equalTo(404)) @@ -92,7 +92,7 @@ public void testMainError_Case1InvocationError() { given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(500) .contentType(IntegroAPI.ERROR_CONTENT_TYPE) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_CALLING_JAVA_METHOD)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_CALLING_JAVA_METHOD)) .body("detail", Matchers.containsString( "We do not allow numbers that are equal.")) .body("code", Matchers.equalTo(500)) @@ -118,7 +118,7 @@ public void testMainEror_Case2AmbiguousMethodException() { l_call.getCallContent().put("call1PL", myContent); given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_AMBIGUOUS_METHOD)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_AMBIGUOUS_METHOD)) .body("detail", Matchers.containsString( "We could not find a unique method for")) .body("code", Matchers.equalTo(404)) @@ -139,7 +139,7 @@ public void testMainEror_Case4A_NonExistantJavaException() { l_call.getCallContent().put("call1PL", myContent); given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JAVA_OBJECT_NOT_FOUND)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JAVA_OBJECT_NOT_FOUND)) .body("detail", Matchers.containsString( "The given class com.adobe.campaign.tests.bridgeservice.testdata.SimpleStaticMethodsNonExisting could not be found.")) .body("code", Matchers.equalTo(404)) @@ -186,7 +186,7 @@ public void testMainEror_passiingNull() throws JsonProcessingException { + "}"; given().body(l_jsonString).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JSON_TRANSFORMATION)); + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JSON_TRANSFORMATION)); } diff --git a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ETests.java b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ETests.java index aac36ba..f9204c4 100644 --- a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ETests.java +++ b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/E2ETests.java @@ -108,7 +108,7 @@ public void testErrors() { JavaCallResults jcr = new JavaCallResults(); given().body(jcr).post(EndPointURL + "call").then().statusCode(404).and().assertThat() - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JSON_TRANSFORMATION)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JSON_TRANSFORMATION)) .body("detail", Matchers.startsWith( "Unrecognized field \"callDurations\" (class com.adobe.campaign.tests.bridge.service.JavaCalls), not marked as ignorable")) .body("code", Matchers.equalTo(404)) @@ -146,7 +146,7 @@ public void testMainEror_Case1InvocationError() { given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(500) .contentType(IntegroAPI.ERROR_CONTENT_TYPE) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_CALLING_JAVA_METHOD)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_CALLING_JAVA_METHOD)) .body("detail", Matchers.containsString( "We do not allow numbers that are equal.")) .body("code", Matchers.equalTo(500)) @@ -173,7 +173,7 @@ public void testMainError_Case2AmbiguousMethodException() { l_call.getCallContent().put("call1PL", myContent); given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_AMBIGUOUS_METHOD)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_AMBIGUOUS_METHOD)) .body("detail", Matchers.containsString( "We could not find a unique method for")) .body("code", Matchers.equalTo(404)) @@ -205,7 +205,7 @@ public void testMainEror_Case3IBSConfigurationException1() { l_call.setEnvironmentVariables(l_envVars); given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(500) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_IBS_CONFIG)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_IBS_CONFIG)) .body("detail", Matchers.containsString( "The given environment value handler")) .body("detail", Matchers.containsString( @@ -229,7 +229,7 @@ public void testMainEror_Case4A_NonExistantJavaException() { l_call.getCallContent().put("call1PL", myContent); given().body(l_call).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JAVA_OBJECT_NOT_FOUND)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JAVA_OBJECT_NOT_FOUND)) .body("detail", Matchers.containsString( "The given class com.adobe.campaign.tests.bridgeservice.testdata.SimpleStaticMethodsNonExisting could not be found.")) .body("code", Matchers.equalTo(404)) @@ -280,7 +280,7 @@ public void testMainEror_passingNull() { + "}"; given().body(l_jsonString).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JSON_TRANSFORMATION)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JSON_TRANSFORMATION)) .body("failureAtStep", Matchers.equalTo(LogManagement.STD_STEPS.ANALYZING_PAYLOAD.value)); } @@ -408,7 +408,7 @@ public void testTimeOutCalls() { jc.getCallContent().put("call1", cc1); given().body(jc).post(EndPointURL + "call").then().assertThat().statusCode(408) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_CALL_TIMEOUT)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_CALL_TIMEOUT)) .body("detail", Matchers.containsString( "took longer than the set time limit of")) .body("code", Matchers.equalTo(408)) @@ -452,7 +452,7 @@ public void testTimeOutCalls_overrideFAIL() { jc.getCallContent().put("call1", cc1); given().body(jc).post(EndPointURL + "call").then().assertThat().statusCode(408) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_CALL_TIMEOUT)); + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_CALL_TIMEOUT)); } /** @@ -550,7 +550,7 @@ public void testIssue34Manual_Negative() { l_myJavaCalls.getCallContent().put("call2", l_cc2); given().body(l_myJavaCalls).post(EndPointURL + "call").then().assertThat().statusCode(500). - body("title", Matchers.equalTo(IntegroAPI.ERROR_IBS_CONFIG)) + body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_IBS_CONFIG)) .body("code", Matchers.equalTo(500)) .body("detail", Matchers.startsWith("Linkage Error detected")) .body("bridgeServiceException", Matchers.equalTo(IBSConfigurationException.class.getTypeName())) @@ -568,7 +568,7 @@ public void test_issue35_callToClassWithNoModifiers() { jc.getCallContent().put("one", l_cc); given().body(jc).post(EndPointURL + "call").then().assertThat().statusCode(404) - .body("title", Matchers.equalTo(IntegroAPI.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)) + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)) .body("bridgeServiceException", Matchers.equalTo(JavaObjectInaccessibleException.class.getTypeName())) .body("detail", Matchers.startsWith( "We do not have the right to execute the given class.")) @@ -687,7 +687,7 @@ public void testExternalErrorCall() { .body("originalException", Matchers.equalTo("java.lang.IllegalArgumentException")) .body("originalMessage", Matchers.equalTo("Will always throw this")) .body("stackTrace[0]", Matchers.startsWith( - "com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods.methodThrowsException(SimpleStaticMethods.java:6")); + "com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods.methodThrowsException(SimpleStaticMethods.java:125")); } @@ -749,11 +749,11 @@ public void run() { } assertThat("We should be able to get the body", l_call1Result[0].get("title"), - Matchers.equalTo(IntegroAPI.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)); + Matchers.equalTo(BridgeServiceFactory.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)); assertThat("We should be able to get the body", l_call1Result[0].get("failureAtStep"), Matchers.equalTo("step1")); assertThat("We should be able to get the body", l_call2Result[0].get("title"), - Matchers.equalTo(IntegroAPI.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)); + Matchers.equalTo(BridgeServiceFactory.ERROR_JAVA_OBJECT_NOT_ACCESSIBLE)); assertThat("We should be able to get the body", l_call2Result[0].get("failureAtStep"), Matchers.equalTo("step2")); } @@ -819,7 +819,7 @@ public void testFetchHeaders_negative() { given().body(l_myJavaCall).header("IBS_HEADER_1", "REAL").post(EndPointURL + "call").then().statusCode(404) .assertThat() - .body("title", Matchers.equalTo(IntegroAPI.ERROR_PAYLOAD_INCONSISTENCY)); + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_PAYLOAD_INCONSISTENCY)); } @@ -838,7 +838,7 @@ public void testFetchHeaders_negativeXsideScripting() { given().body(l_myJavaCall).header("ibs-header-1", "REAL").post(EndPointURL + "call").then().statusCode(404) .assertThat() - .body("title", Matchers.equalTo(IntegroAPI.ERROR_PAYLOAD_INCONSISTENCY)); + .body("title", Matchers.equalTo(BridgeServiceFactory.ERROR_PAYLOAD_INCONSISTENCY)); } @Test(groups = "E2E") diff --git a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPBridgeServerTest.java b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPBridgeServerTest.java new file mode 100644 index 0000000..80e1725 --- /dev/null +++ b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPBridgeServerTest.java @@ -0,0 +1,906 @@ +/* + * Copyright 2022 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ +package com.adobe.campaign.tests.bridge.service; + +import io.restassured.response.Response; +import org.hamcrest.Matchers; +import org.testng.annotations.AfterGroups; +import org.testng.annotations.BeforeGroups; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import spark.Spark; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Integration tests for the MCP endpoint (POST /mcp). + * + * Follows the same in-process server lifecycle as E2ETests: @BeforeGroups starts Spark + * with MCP enabled, REST-assured sends raw JSON-RPC 2.0 requests to /mcp, @AfterGroups + * stops Spark. Tests run within the "MCP" group. + */ +public class MCPBridgeServerTest { + + public static final String MCP_ENDPOINT = "http://localhost:8080/mcp"; + private static final String TESTDATA_ONE_PACKAGE = "com.adobe.campaign.tests.bridge.testdata.one"; + private static final String CONTENT_TYPE_JSON = "application/json"; + + @BeforeGroups(groups = "MCP") + public void startMCPService() { + ConfigValueHandlerIBS.STATIC_INTEGRITY_PACKAGES.activate(TESTDATA_ONE_PACKAGE); + ConfigValueHandlerIBS.MCP_ENABLED.activate("true"); + IntegroAPI.startServices(8080); + Spark.awaitInitialization(); + } + + @BeforeMethod + public void resetConfigBetweenTests() { + // Re-apply packages so each tools/call can load classes via the custom class loader. + // The MCP server was started with these packages; we re-activate them after each + // BeforeMethod reset so call execution continues to work. + ConfigValueHandlerIBS.STATIC_INTEGRITY_PACKAGES.activate(TESTDATA_ONE_PACKAGE); + } + + @AfterGroups(groups = "MCP", alwaysRun = true) + public void stopMCPService() { + ConfigValueHandlerIBS.resetAllValues(); + Spark.stop(); + } + + // ---- initialize handshake ---- + + @Test(groups = "MCP") + public void testInitialize_returnsProtocolVersion() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + + "\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"}," + + "\"capabilities\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("jsonrpc", equalTo("2.0")) + .body("result.protocolVersion", equalTo("2024-11-05")) + .body("result.serverInfo.name", equalTo("bridgeService")) + .body("result.capabilities.tools", notNullValue()); + } + + // ---- tools/list ---- + + @Test(groups = "MCP") + public void testToolsList_returnsDiscoveredTools() { + // Only java_call is a callable tool; the catalog of discovered methods is embedded + // in its description so the LLM can construct the right callContent payload. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools", hasSize(2)) + .body("result.tools.name", hasItem("java_call")) + .body("result.tools.find { it.name == 'java_call' }.description", + containsString("SimpleStaticMethods_methodReturningString")); + } + + @Test(groups = "MCP") + public void testToolsList_eachToolHasRequiredFields() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools[0].name", notNullValue()) + .body("result.tools[0].description", notNullValue()) + .body("result.tools[0].inputSchema", notNullValue()); + } + + @Test(groups = "MCP") + public void testToolsList_descriptionComesFromJavadoc() { + // The catalog entry for methodReturningString uses its Javadoc text, not the + // fallback "Calls com.example.MyClass.methodName()" string. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools.find { it.name == 'java_call' }.description", + containsString("success string")); + } + + @Test(groups = "MCP") + public void testToolsList_noArgToolHasEmptyProperties() { + Response resp = given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .extract().response(); + + // methodReturningString is in the catalog — its entry must appear in java_call description + String desc = resp.path("result.tools.find { it.name == 'java_call' }.description"); + assertThat(desc, containsString("SimpleStaticMethods_methodReturningString")); + } + + @Test(groups = "MCP") + public void testToolsList_undocumentedMethodExcluded() { + // EnvironmentVariableHandler methods have no Javadoc — must be absent from the + // catalog (IBS.MCP.REQUIRE_JAVADOC defaults to true). + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools", hasSize(2)) + .body("result.tools.find { it.name == 'java_call' }.description", + not(containsString("EnvironmentVariableHandler_getCacheProperty"))) + .body("result.tools.find { it.name == 'java_call' }.description", + not(containsString("EnvironmentVariableHandler_setIntegroCache"))); + } + + // ---- ibs_diagnostics tool ---- + + @Test(groups = "MCP") + public void testToolsList_includesDiagnosticsTool() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools", hasSize(2)) + .body("result.tools.name", hasItem("ibs_diagnostics")) + .body("result.tools.find { it.name == 'ibs_diagnostics' }.description", notNullValue()) + .body("result.tools.find { it.name == 'ibs_diagnostics' }.inputSchema", notNullValue()); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_basicCall_returnsExpectedFields() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":51,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].type", equalTo("text")) + .body("result.content[0].text", containsString("ibsVersion")) + .body("result.content[0].text", containsString("deploymentMode")) + .body("result.content[0].text", containsString("mcpConfig")) + .body("result.content[0].text", containsString("headers")) + .body("result.content[0].text", containsString("discoveredToolCount")); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_mcpConfigReflectsCurrentState() { + Response resp = given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":52,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .extract().response(); + + String text = resp.path("result.content[0].text"); + assertThat(text, containsString("packagesConfigured")); + assertThat(text, containsString(TESTDATA_ONE_PACKAGE)); + assertThat(text, containsString("\"prechainActive\":false")); + assertThat(text, containsString("\"javadocRequired\":true")); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_secretHeaders_keysReportedValuesAbsent() { + given() + .contentType(CONTENT_TYPE_JSON) + .header("ibs-secret-api-key", "MY_SECRET_VALUE") + .header("ibs-secret-token", "ANOTHER_SECRET") + .body("{\"jsonrpc\":\"2.0\",\"id\":53,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("ibs-secret-api-key")) + .body("result.content[0].text", containsString("ibs-secret-token")) + .body("result.content[0].text", not(containsString("MY_SECRET_VALUE"))) + .body("result.content[0].text", not(containsString("ANOTHER_SECRET"))); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_envVarHeaders_decodedNamesAndValuesReported() { + given() + .contentType(CONTENT_TYPE_JSON) + .header("ibs-env-AC.UITEST.HOST", "example.com") + .header("ibs-env-DEPLOY_ENV", "stage") + .body("{\"jsonrpc\":\"2.0\",\"id\":54,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("AC.UITEST.HOST")) + .body("result.content[0].text", containsString("DEPLOY_ENV")) + .body("result.content[0].text", containsString("example.com")) + .body("result.content[0].text", containsString("stage")); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_noSpecialHeaders_emptyLists() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":55,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("\"secretHeaderKeys\":[]")) + .body("result.content[0].text", containsString("\"envVarHeaders\":{}")); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_discoveredToolCount_isPositive() { + Response resp = given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":56,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .extract().response(); + + String text = resp.path("result.content[0].text"); + assertThat(text, containsString("\"discoveredToolCount\"")); + assertThat(text, not(containsString("\"discoveredToolCount\":0"))); + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_prechainActive_reflectsConfig() { + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}}"); + try { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":57,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("\"prechainActive\":true")); + } finally { + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + } + + @Test(groups = "MCP") + public void testDiagnosticsTool_prechainActive_trueWhenProvidedViaHeader() { + given() + .contentType(CONTENT_TYPE_JSON) + .header(MCPRequestHandler.PRECHAIN_HEADER, + "{\"ibs_auth\":{\"class\":\"utils.CampaignUtils\"," + + "\"method\":\"setCurrentAuthenticationToLocal\"," + + "\"args\":[\"ibs-secret-url\",\"ibs-secret-login\",\"ibs-secret-pass\"]}}") + .body("{\"jsonrpc\":\"2.0\",\"id\":58,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"ibs_diagnostics\",\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("\"prechainActive\":true")); + } + + // ---- type handling ---- + + @Test(groups = "MCP") + public void testJavaCall_intArgument_jsonIntegerSucceeds() { + // A JSON integer (42) must be accepted by a method expecting int. + // Jackson deserialises it as Integer; Java reflection widens Integer → int. + // Expected return: 42 * 3 = 126. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingIntArgument\"," + + "\"args\":[42]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("126")); + } + + @Test(groups = "MCP") + public void testJavaCall_intArgument_jsonStringCoerced() { + // A JSON string "42" is coerced to int by castArgs before invocation. + // Expected return: 42 * 3 = 126. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingIntArgument\"," + + "\"args\":[\"42\"]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("126")); + } + + @Test(groups = "MCP") + public void testJavaCall_intArgument_unparsableStringFails() { + // A JSON string that cannot be parsed as int ("hello") → isError with a structured + // ErrorObject that names the problematic value and target type. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingIntArgument\"," + + "\"args\":[\"hello\"]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(true)) + .body("result.content[0].text", containsString("\"title\"")) + .body("result.content[0].text", containsString("hello")); + } + + // ---- tools/call (via java_call) ---- + + @Test(groups = "MCP") + public void testToolsCall_noArgMethod_returnsResult() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].type", equalTo("text")) + .body("result.content[0].text", containsString("_Success")); + } + + @Test(groups = "MCP") + public void testToolsCall_withStringArg_returnsResult() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingStringArgument\"," + + "\"args\":[\"hello\"]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("hello")) + .body("result.content[0].text", containsString("_Success")); + } + + @Test(groups = "MCP") + public void testToolsCall_unknownTool_returnsIsError() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"NonExistent_toolName\"," + + "\"arguments\":{}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(true)) + .body("result.content[0].text", containsString("NonExistent_toolName")); + } + + @Test(groups = "MCP") + public void testToolsCall_methodThrowsException_returnsIsError() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodThrowsException\"," + + "\"args\":[]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(true)) + // Error text must be a full ErrorObject JSON, not a bare exception string + .body("result.content[0].text", containsString("\"title\"")) + .body("result.content[0].text", containsString("\"detail\"")) + .body("result.content[0].text", containsString("\"originalException\"")); + } + + @Test(groups = "MCP") + public void testToolsCall_timeout_returnsIsError() { + // methodWithTimeOut sleeps for the given ms; set a short IBS timeout + ConfigValueHandlerIBS.DEFAULT_CALL_TIMEOUT.activate("500"); + + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodWithTimeOut\"," + + "\"args\":[5000]}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(true)) + .body("result.content[0].text", containsString("\"title\"")) + .body("result.content[0].text", containsString("timeout")); + } + + // ---- tools/call (java_call fallback) ---- + + @Test(groups = "MCP") + public void testJavaCallTool_basicCall_returnsResult() { + String payload = "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]" + + "}}}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("_Success")); + } + + @Test(groups = "MCP") + public void testJavaCallTool_callContentAsString_isUnwrapped() { + // MCP clients may serialise the callContent object as a JSON string rather than a + // nested object. The handler must unwrap it and execute the call normally. + String callContentJson = "{\\\"result\\\":{\\\"class\\\":\\\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\\\"," + + "\\\"method\\\":\\\"methodReturningString\\\",\\\"args\\\":[]}}"; + String payload = "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{\"callContent\":\"" + callContentJson + "\"}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("_Success")); + } + + // ---- unknown JSON-RPC method ---- + + @Test(groups = "MCP") + public void testUnknownMethod_returnsJsonRpcError() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"unknown/method\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("error.code", equalTo(-32601)) + .body("error.message", containsString("unknown/method")); + } + + // ---- regression: existing /call endpoint unaffected ---- + + @Test(groups = "MCP") + public void testExistingCallEndpoint_stillWorks() { + String payload = "{\"callContent\":{\"step1\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post("http://localhost:8080/call") + .then() + .statusCode(200) + .body("returnValues.step1", Matchers.equalTo("_Success")); + } + + // ---- IBS.MCP.PRECHAIN ---- + + @Test(groups = "MCP") + public void testPrechain_isExecutedAndResultStripped() { + // Pre-chain runs a no-arg method; its key must be absent from returnValues, + // while the actual call result ("result") must be present. + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}}"); + + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", not(containsString("\"ibs_pre\""))) + .body("result.content[0].text", containsString("\"result\"")); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testPrechain_dependencyResolutionBetweenPrechainSteps() { + // Second pre-chain entry references the first by key — both must execute without error. + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre1\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}," + + "\"ibs_pre2\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingStringArgument\",\"args\":[\"ibs_pre1\"]}}"); + + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testPrechain_secretHeaderArgIsResolved() { + // A prechain arg that matches an ibs-secret-* request header is resolved to the + // header value via the existing expandArgs mechanism (no error expected). + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingStringArgument\",\"args\":[\"ibs-secret-test-val\"]}}"); + + given() + .contentType(CONTENT_TYPE_JSON) + .header("ibs-secret-test-val", "RESOLVED") + .body("{\"jsonrpc\":\"2.0\",\"id\":22,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", not(containsString("ibs-secret-test-val"))); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testPrechain_malformedJsonIsSkippedGracefully() { + ConfigValueHandlerIBS.MCP_PRECHAIN.activate("not valid json {{{"); + + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("_Success")); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testPrechain_appliedToJavaCall_andResultStripped() { + // PRECHAIN must run before the user's java_call chain, and its keys must be + // stripped from the result — consistent with auto-discovered tool behaviour. + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}}"); + + String payload = "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]" + + "}}}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", not(containsString("\"ibs_pre\""))) + .body("result.content[0].text", containsString("\"result\"")); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testPrechain_javaCallCanReferenceToPrechain() { + // A java_call step can reference a PRECHAIN step's result by key — enabling + // the auth-object pattern where PRECHAIN establishes auth and the chain passes it along. + ConfigValueHandlerIBS.MCP_PRECHAIN.activate( + "{\"ibs_pre\":{\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\",\"args\":[]}}"); + + // The user's step references "ibs_pre" — BridgeService substitutes the return value. + String payload = "{\"jsonrpc\":\"2.0\",\"id\":26,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodAcceptingStringArgument\"," + + "\"args\":[\"ibs_pre\"]" + + "}}}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + // "ibs_pre" resolved to "_Success"; methodAcceptingStringArgument appends "_Success" + .body("result.content[0].text", containsString("_Success_Success")); + + ConfigValueHandlerIBS.MCP_PRECHAIN.reset(); + } + + @Test(groups = "MCP") + public void testJavaCallTool_callChaining_complexObjectPassedByReference() { + // Proves that java_call call chaining works end-to-end through the MCP HTTP layer: + // step A returns a List (a complex, non-JSON-serializable object); + // step B receives it by reference inside the same classloader context and extracts subjects. + // This mirrors the chainingComplexCalls unit test in TestFetchCalls but exercises + // the full JSON-RPC 2.0 → MCPRequestHandler → JavaCalls.submitCalls() path. + String payload = "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"fetchMessages\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.MimeMessageMethods\"," + + "\"method\":\"fetchMessages\"," + + "\"args\":[\"mcpChain\",4]" + + "}," + + "\"fetchSubjects\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.MimeMessageMethods\"," + + "\"method\":\"fetchMessageSubjects\"," + + "\"args\":[\"fetchMessages\"]" + + "}}}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("fetchSubjects")) + .body("result.content[0].text", containsString("mcpChain_3")); + } + + @Test(groups = "MCP") + public void testJavaCall_descriptionMentionsCallChaining() { + // The java_call tool description must contain guidance about call chaining. + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"id\":25,\"method\":\"tools/list\",\"params\":{}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.tools.find { it.name == 'java_call' }.description", + containsString("isolated")); + } + + // ---- ibs-env-* header injection ---- + + @Test(groups = "MCP") + public void testToolsCall_envHeadersInjected_discoveredTool() { + // Configure the env-var setter to the in-package EnvironmentVariableHandler so the + // test can verify that ENVVAR1 and ENVVAR2 were injected into the call context. + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_CLASS.activate( + "com.adobe.campaign.tests.bridge.testdata.one.EnvironmentVariableHandler"); + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_METHOD.activate("setIntegroCache"); + + given() + .contentType(CONTENT_TYPE_JSON) + .header("ibs-env-ENVVAR1", "hello") + .header("ibs-env-ENVVAR2", "world") + .body("{\"jsonrpc\":\"2.0\",\"id\":40,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"usesEnvironmentVariables\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("hello_world")); + + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_CLASS.reset(); + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_METHOD.reset(); + } + + @Test(groups = "MCP") + public void testToolsCall_envHeadersInjected_javaCallTool() { + // Same verification for the java_call path. + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_CLASS.activate( + "com.adobe.campaign.tests.bridge.testdata.one.EnvironmentVariableHandler"); + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_METHOD.activate("setIntegroCache"); + + String payload = "{\"jsonrpc\":\"2.0\",\"id\":41,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"usesEnvironmentVariables\"," + + "\"args\":[]" + + "}}}}}"; + + given() + .contentType(CONTENT_TYPE_JSON) + .header("ibs-env-ENVVAR1", "foo") + .header("ibs-env-ENVVAR2", "bar") + .body(payload) + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("foo_bar")); + + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_CLASS.reset(); + ConfigValueHandlerIBS.ENVIRONMENT_VARS_SETTER_METHOD.reset(); + } + + @Test(groups = "MCP") + public void testToolsCall_envHeadersDoNotLeakToNonEnvHeaders() { + // Headers without the ibs-env- prefix must not be treated as env vars. + // If no ibs-env-* headers are sent, result relies on unset cache — no crash expected. + given() + .contentType(CONTENT_TYPE_JSON) + .header("x-custom-header", "somevalue") + .body("{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"java_call\"," + + "\"arguments\":{" + + "\"callContent\":{" + + "\"result\":{" + + "\"class\":\"com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods\"," + + "\"method\":\"methodReturningString\"," + + "\"args\":[]}}}}}}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(200) + .body("result.isError", equalTo(false)) + .body("result.content[0].text", containsString("_Success")); + } + + // ---- notification handling ---- + + @Test(groups = "MCP") + public void testNotification_returns202() { + given() + .contentType(CONTENT_TYPE_JSON) + .body("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}") + .when() + .post(MCP_ENDPOINT) + .then() + .statusCode(202); + } +} diff --git a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscoveryTest.java b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscoveryTest.java new file mode 100644 index 0000000..89d76de --- /dev/null +++ b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/MCPToolDiscoveryTest.java @@ -0,0 +1,260 @@ +/* + * Copyright 2022 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ +package com.adobe.campaign.tests.bridge.service; + +import com.adobe.campaign.tests.bridge.testdata.one.EnvironmentVariableHandler; +import com.adobe.campaign.tests.bridge.testdata.one.SimpleStaticMethods; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +public class MCPToolDiscoveryTest { + + private static final String TESTDATA_ONE_PACKAGE = "com.adobe.campaign.tests.bridge.testdata.one"; + + // ---- discoverTools ---- + + @Test + public void testDiscoverTools_emptyPackages_returnsEmpty() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(""); + assertThat(result.tools, is(empty())); + assertThat(result.methodRegistry, is(anEmptyMap())); + } + + @Test + public void testDiscoverTools_nullPackages_returnsEmpty() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(null); + assertThat(result.tools, is(empty())); + assertThat(result.methodRegistry, is(anEmptyMap())); + } + + @Test + public void testDiscoverTools_findsKnownStaticMethod() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + // methodReturningString() is public static — must be discovered + assertThat(result.methodRegistry.keySet(), + hasItem("SimpleStaticMethods_methodReturningString")); + } + + @Test + public void testDiscoverTools_toolListMatchesRegistry() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + // Every tool in the list must have a matching entry in the registry + for (Map tool : result.tools) { + String name = (String) tool.get("name"); + assertThat("Tool '" + name + "' missing from registry", result.methodRegistry, hasKey(name)); + } + assertThat(result.tools.size(), equalTo(result.methodRegistry.size())); + } + + @Test + public void testDiscoverTools_toolHasRequiredFields() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + Map tool = result.tools.stream() + .filter(t -> "SimpleStaticMethods_methodReturningString".equals(t.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Tool not found")); + + assertThat(tool, hasKey("name")); + assertThat(tool, hasKey("description")); + assertThat(tool, hasKey("inputSchema")); + // Description is sourced from Javadoc — verify it reflects the actual comment, not the fallback + assertThat((String) tool.get("description"), containsString("success string")); + } + + @Test + public void testDiscoverTools_nonStaticMethodExcluded() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + // methodAcceptingStringAndArray is an instance method in SimpleStaticMethods (Issue #176) + assertThat(result.methodRegistry.keySet(), + not(hasItem("SimpleStaticMethods_methodAcceptingStringAndArray"))); + } + + @Test + public void testDiscoverTools_ambiguousOverloadsSkipped() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + // overLoadedMethod1Arg has two variants both with 1 parameter — must be skipped + assertThat(result.methodRegistry.keySet(), + not(hasItem("SimpleStaticMethods_overLoadedMethod1Arg_1"))); + } + + @Test + public void testDiscoverTools_unambiguousOverloadsDisambiguated() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + // methodThrowingException exists with 0 params (methodThrowsException is different name) + // and with 2 params — these have different param counts so both should be registered + // with suffix: SimpleStaticMethods_methodThrowingException_0 is NOT present because + // methodThrowsException() is a distinct method name (0 params) + // methodThrowingException(int, int) has 2 params and is the ONLY one with that count + // So it should appear as SimpleStaticMethods_methodThrowingException_2 only if there + // are multiple overloads by name. Let's check what we get: + // - methodThrowsException() → unique by name → SimpleStaticMethods_methodThrowsException + // - methodThrowingException(int, int) → unique by name → SimpleStaticMethods_methodThrowingException + + // Both are unique by name so they get simple names (no param count suffix) + assertThat(result.methodRegistry.keySet(), + hasItem("SimpleStaticMethods_methodThrowsException")); + assertThat(result.methodRegistry.keySet(), + hasItem("SimpleStaticMethods_methodThrowingException")); + } + + @Test + public void testDiscoverTools_trailingDotInPackageHandled() { + // IBS config often uses trailing dots for package patterns + MCPToolDiscovery.DiscoveryResult result = + MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE + "."); + assertThat(result.methodRegistry.keySet(), + hasItem("SimpleStaticMethods_methodReturningString")); + } + + @Test + public void testDiscoverTools_registryMethodMatchesClass() { + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + Method m = result.methodRegistry.get("SimpleStaticMethods_methodReturningString"); + assertThat(m, notNullValue()); + assertThat(m.getDeclaringClass(), equalTo(SimpleStaticMethods.class)); + assertThat(m.getName(), equalTo("methodReturningString")); + } + + // ---- hasJavadoc ---- + + @Test + public void testHasJavadoc_methodWithJavadoc_returnsTrue() throws Exception { + java.lang.reflect.Method m = SimpleStaticMethods.class.getMethod("methodReturningString"); + assertThat(MCPToolDiscovery.hasJavadoc(m), is(true)); + } + + @Test + public void testHasJavadoc_methodWithoutJavadoc_returnsFalse() throws Exception { + java.lang.reflect.Method m = EnvironmentVariableHandler.class.getMethod("getCacheProperty", String.class); + assertThat(MCPToolDiscovery.hasJavadoc(m), is(false)); + } + + // ---- MCP_REQUIRE_JAVADOC gate ---- + + @AfterMethod + public void resetConfig() { + ConfigValueHandlerIBS.MCP_REQUIRE_JAVADOC.reset(); + } + + @Test + public void testDiscoverTools_requireJavadocTrue_excludesUndocumentedMethods() { + // Default is true — EnvironmentVariableHandler methods have no Javadoc + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + assertThat(result.methodRegistry.keySet(), + not(hasItem("EnvironmentVariableHandler_getCacheProperty"))); + assertThat(result.methodRegistry.keySet(), + not(hasItem("EnvironmentVariableHandler_setIntegroCache"))); + // Documented methods are still present + assertThat(result.methodRegistry.keySet(), + hasItem("SimpleStaticMethods_methodReturningString")); + } + + @Test + public void testDiscoverTools_requireJavadocFalse_includesUndocumentedMethods() { + ConfigValueHandlerIBS.MCP_REQUIRE_JAVADOC.activate("false"); + MCPToolDiscovery.DiscoveryResult result = MCPToolDiscovery.discoverTools(TESTDATA_ONE_PACKAGE); + + assertThat(result.methodRegistry.keySet(), + hasItem("EnvironmentVariableHandler_getCacheProperty")); + } + + // ---- buildInputSchema ---- + + @Test + public void testBuildInputSchema_noParams_emptyProperties() throws Exception { + Method m = SimpleStaticMethods.class.getMethod("methodReturningString"); + Map schema = MCPToolDiscovery.buildInputSchema(m); + + assertThat(schema.get("type"), equalTo("object")); + assertThat((Map) schema.get("properties"), is(anEmptyMap())); + assertThat(schema, not(hasKey("required"))); + } + + @Test + public void testBuildInputSchema_stringParam() throws Exception { + Method m = SimpleStaticMethods.class.getMethod("methodAcceptingStringArgument", String.class); + Map schema = MCPToolDiscovery.buildInputSchema(m); + + Map props = (Map) schema.get("properties"); + assertThat(props, hasKey("arg0")); + assertThat(((Map) props.get("arg0")).get("type"), equalTo("string")); + + List required = (List) schema.get("required"); + assertThat(required, contains("arg0")); + } + + @Test + public void testBuildInputSchema_intParam() throws Exception { + Method m = SimpleStaticMethods.class.getMethod("methodAcceptingIntArgument", int.class); + Map schema = MCPToolDiscovery.buildInputSchema(m); + + Map props = (Map) schema.get("properties"); + assertThat(((Map) props.get("arg0")).get("type"), equalTo("integer")); + } + + @Test + public void testBuildInputSchema_twoParams_requiredListOrdered() throws Exception { + Method m = SimpleStaticMethods.class.getMethod("methodAcceptingTwoArguments", String.class, String.class); + Map schema = MCPToolDiscovery.buildInputSchema(m); + + List required = (List) schema.get("required"); + assertThat(required, contains("arg0", "arg1")); + } + + @Test + public void testBuildInputSchema_listParam() throws Exception { + Method m = SimpleStaticMethods.class.getMethod("methodAcceptingListArguments", java.util.List.class); + Map schema = MCPToolDiscovery.buildInputSchema(m); + + Map props = (Map) schema.get("properties"); + assertThat(((Map) props.get("arg0")).get("type"), equalTo("array")); + } + + // ---- javaTypeToJsonSchemaType ---- + + @Test + public void testTypeMapping_primitives() { + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(String.class), equalTo("string")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(int.class), equalTo("integer")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(Integer.class), equalTo("integer")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(long.class), equalTo("integer")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(Long.class), equalTo("integer")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(double.class), equalTo("number")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(Double.class), equalTo("number")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(float.class), equalTo("number")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(boolean.class), equalTo("boolean")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(Boolean.class), equalTo("boolean")); + } + + @Test + public void testTypeMapping_collections() { + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(java.util.List.class), equalTo("array")); + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(String[].class), equalTo("array")); + } + + @Test + public void testTypeMapping_unknownType_returnsObject() { + assertThat(MCPToolDiscovery.javaTypeToJsonSchemaType(java.io.File.class), equalTo("object")); + } +} diff --git a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/TestFetchCalls.java b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/TestFetchCalls.java index b2147f9..d491a2c 100644 --- a/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/TestFetchCalls.java +++ b/integroBridgeService/src/test/java/com/adobe/campaign/tests/bridge/service/TestFetchCalls.java @@ -2091,6 +2091,70 @@ public void testInListToArrayTransformationBug176() throws NoSuchMethodException assertThat("We should get the correct return value", l_myJavaCall.call("call1"), Matchers.equalTo(5)); } + // ---- coerceArg unit tests ---- + + @Test + public void testCoerceArg_stringToInt() { + assertThat(new CallContent().coerceArg("42", int.class), Matchers.equalTo(42)); + } + + @Test + public void testCoerceArg_stringToBoxedInteger() { + assertThat(new CallContent().coerceArg("42", Integer.class), Matchers.equalTo(42)); + } + + @Test + public void testCoerceArg_stringToLong() { + assertThat(new CallContent().coerceArg("123456789012", long.class), Matchers.equalTo(123456789012L)); + } + + @Test + public void testCoerceArg_stringToDouble() { + assertThat((Double) new CallContent().coerceArg("3.14", double.class), + Matchers.closeTo(3.14, 0.001)); + } + + @Test + public void testCoerceArg_stringToFloat() { + assertThat(new CallContent().coerceArg("1.5", float.class), Matchers.equalTo(1.5f)); + } + + @Test + public void testCoerceArg_stringToBooleanTrue() { + assertThat(new CallContent().coerceArg("true", boolean.class), Matchers.equalTo(true)); + } + + @Test + public void testCoerceArg_stringToBooleanFalse() { + assertThat(new CallContent().coerceArg("false", Boolean.class), Matchers.equalTo(false)); + } + + @Test + public void testCoerceArg_stringTargetPassthrough() { + // String → String: no conversion needed, value passes through unchanged. + assertThat(new CallContent().coerceArg("hello", String.class), Matchers.equalTo("hello")); + } + + @Test + public void testCoerceArg_nonStringPassthrough() { + // Integer → Integer: already the right type, passes through. + assertThat(new CallContent().coerceArg(42, Integer.class), Matchers.equalTo(42)); + } + + @Test(expectedExceptions = NonExistentJavaObjectException.class) + public void testCoerceArg_unparsableString_throwsNonExistentJavaObjectException() { + // "hello" cannot be parsed as int → NonExistentJavaObjectException with a clear message. + new CallContent().coerceArg("hello", int.class); + } + + @Test + public void testCoerceArg_listToArray() { + // List → Array: existing behaviour verified via the extracted method. + Object result = new CallContent().coerceArg(List.of("a", "b"), String[].class); + assertThat(result.getClass().isArray(), Matchers.equalTo(true)); + assertThat(result.getClass().getComponentType(), Matchers.equalTo(String.class)); + } + @Test public void testMetaIsListToArray() throws NoSuchMethodException { diff --git a/pom.xml b/pom.xml index 85fa932..9eb1d50 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ 4.0.0 com.adobe.campaign.tests.bridge parent - 2.11.19-SNAPSHOT + 3.11.0-SNAPSHOT Bridge Service Parent Project pom ${project.groupId}:${project.artifactId} @@ -202,11 +202,11 @@ ossrh - https://oss.sonatype.org/content/repositories/snapshots + https://central.sonatype.com/repository/maven-snapshots/ ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/