diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc4e4a..00add43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,34 @@ jobs: - name: Package run: mvn -B package -DskipTests + + - name: Test core + run: mvn -B test + + mcp-ci: + name: MCP adapter CI + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: maven + + - name: Install matching core artifact + run: | + adapter_version="$(mvn -q -f mcp/pom.xml help:evaluate -Dexpression=project.version -DforceStdout)" + mvn -B versions:set -DnewVersion="${adapter_version}" -DgenerateBackupPoms=false + mvn -B install -DskipTests + + - name: Test and install optional MCP adapter + run: mvn -B -f mcp/pom.xml install + + - name: Compile self-hosted MCP example + run: mvn -B -f examples/self_hosted_mcp_worker/pom.xml package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c5e4b0..f354972 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Java Release -# Publishes com.volcengine:ark-runtime to Maven Central. Tags are created +# Publishes com.volcengine:ark-runtime and the optional ark-runtime-mcp adapter +# to Maven Central. Tags are created # exclusively by ark-hand after a sync PR lands on main, so a pushed v* tag # is always a reviewed release snapshot whose tree matches the internal # source tree. Mirrors the proven setup from volcengine/volcengine-java-sdk: @@ -58,7 +59,7 @@ jobs: with: ref: ${{ steps.tag.outputs.name }} - - name: Verify pom version matches tag + - name: Verify artifact versions match tag env: RELEASE_TAG: ${{ steps.tag.outputs.name }} run: | @@ -72,11 +73,22 @@ jobs: echo "::error::pom.xml ark-runtime version ${actual} does not match tag ${RELEASE_TAG}" exit 1 fi + mcp_actual=$(python3 -c " + import re + text = open('mcp/pom.xml').read() + artifact = re.search(r'ark-runtime-mcp\s*([^<]+)', text) + dependency = re.search(r'([^<]+)', text) + print((artifact.group(1) if artifact else 'missing') + ' ' + (dependency.group(1) if dependency else 'missing')) + ") + if [ "${mcp_actual}" != "${version} ${version}" ]; then + echo "::error::mcp artifact/core versions ${mcp_actual} do not match tag ${RELEASE_TAG}" + exit 1 + fi - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: "8" + java-version: "17" distribution: "temurin" server-id: central server-username: MAVEN_CENTRAL_USERNAME @@ -84,15 +96,18 @@ jobs: gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: GPG_PASSPHRASE - - name: Check Maven Central version + - name: Check Maven Central versions id: maven_check env: RELEASE_TAG: ${{ steps.tag.outputs.name }} run: | version="${RELEASE_TAG#v}" - pom_url="https://repo.maven.apache.org/maven2/com/volcengine/ark-runtime/${version}/ark-runtime-${version}.pom" - code=$(curl -sS -o /dev/null -w '%{http_code}' "${pom_url}") - echo "publish_needed=$([ "${code}" = "200" ] && echo false || echo true)" >> "$GITHUB_OUTPUT" + core_url="https://repo.maven.apache.org/maven2/com/volcengine/ark-runtime/${version}/ark-runtime-${version}.pom" + mcp_url="https://repo.maven.apache.org/maven2/com/volcengine/ark-runtime-mcp/${version}/ark-runtime-mcp-${version}.pom" + core_code=$(curl -sS -o /dev/null -w '%{http_code}' "${core_url}") + mcp_code=$(curl -sS -o /dev/null -w '%{http_code}' "${mcp_url}") + echo "core_publish_needed=$([ "${core_code}" = "200" ] && echo false || echo true)" >> "$GITHUB_OUTPUT" + echo "mcp_publish_needed=$([ "${mcp_code}" = "200" ] && echo false || echo true)" >> "$GITHUB_OUTPUT" - name: Check publish credentials env: @@ -111,8 +126,8 @@ jobs: exit 1 fi - - name: Publish to Maven Central - if: steps.maven_check.outputs.publish_needed == 'true' + - name: Publish core to Maven Central + if: steps.maven_check.outputs.core_publish_needed == 'true' run: | mvn clean deploy -B -Ppublic -DskipTests \ -Dmaven.javadoc.failOnError=false \ @@ -121,3 +136,14 @@ jobs: MAVEN_CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }} MAVEN_CENTRAL_TOKEN: ${{ secrets.OSSRH_TOKEN }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + - name: Publish MCP adapter to Maven Central + if: steps.maven_check.outputs.mcp_publish_needed == 'true' + run: | + mvn clean deploy -B -f mcp/pom.xml -Ppublic -DskipTests \ + -Dmaven.javadoc.failOnError=false \ + -Dmaven.javadoc.quiet=true + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.OSSRH_TOKEN }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b45aae7..7f020d2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,15 +5,16 @@ from third-party open-source projects. ## Anthropic self-hosted worker SDK -Portions of the self-hosted worker lifecycle and local agent tool -implementations under -`src/main/java/com/volcengine/ark/runtime/selfhosted`, including the work -poller, environment worker, session tool runner, skill initializer, local -tools, and tool-result store, are structurally adapted from Anthropic's -self-hosted worker SDK implementations: +Portions of the self-hosted worker lifecycle, local agent tool, and client-side +MCP helper implementations under +`src/main/java/com/volcengine/ark/runtime/selfhosted` and `mcp`, including the +work poller, environment worker, session tool runner, skill initializer, local +tools, tool-result store, and MCP conversion helpers, are structurally adapted +from Anthropic's SDK implementations: - https://github.com/anthropics/anthropic-sdk-go - https://github.com/anthropics/anthropic-sdk-python +- https://github.com/anthropics/anthropic-sdk-java The upstream projects are licensed under the MIT License. The MIT copyright and permission notice is preserved below as required by that license. diff --git a/examples/README.md b/examples/README.md index d0e9bc5..3c9e729 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,3 +22,5 @@ All service-calling examples are grouped by cloud: The paired multimodal and sparse embedding examples default to `doubao-embedding-vision-251215` / `skylark-embedding-vision-251215`. The paired image examples default to `doubao-seedream-5-0-pro-260628` / `dola-seedream-5-0-pro-260628`. The paired video-generation examples default to `doubao-seedance-2-0-fast-260128` / `dreamina-seedance-2-0-fast-260128`. MCP is available in both clouds and its calls explicitly send `ark-beta-mcp: true`. Other built-in tools are CN-only: Knowledge Search sends `ark-beta-knowledge-search: true`, and Doubao App sends `ark-beta-doubao-app: true`. + +[`self_hosted_mcp_worker/`](./self_hosted_mcp_worker) demonstrates how to discover tools from a local stdio MCP server, convert them into Agent custom tool declarations, and execute them through the self-hosted worker. diff --git a/examples/self_hosted_mcp_worker/README.md b/examples/self_hosted_mcp_worker/README.md new file mode 100644 index 0000000..8b407da --- /dev/null +++ b/examples/self_hosted_mcp_worker/README.md @@ -0,0 +1,124 @@ +# Self-hosted MCP worker + +This example follows Anthropic's client-side MCP helper example at the same +level of abstraction: connect to an MCP server, discover its tools, convert +them, and run an existing self-hosted Environment Worker. + +The self-hosted Environment must exist before starting the worker. Create or +update an Agent with the printed `Agent custom tool` declarations before +creating a Session. Printing declarations does not update the Agent +automatically. The same MCP tool list is registered with the worker for +execution, and the example reads every `tools/list` page. + +Build the standalone example after `ark-runtime` and `ark-runtime-mcp` 0.6.0 +are available in Maven Central or installed in the local Maven repository: + +```bash +mvn -B -f examples/self_hosted_mcp_worker/pom.xml package +``` + +This standalone POM resolves those artifacts from Maven; it is not a reactor +build of the sibling core and `mcp` source directories. + +## Manual end-to-end verification + +The example registers the MCP tool implementation with the self-hosted worker, +but it does not create or update Managed Agents resources. Complete the +following control-plane fields manually: + +1. Create a self-hosted Environment and copy its ID into + `MA_ENVIRONMENT_ID`. +2. Set `ARK_API_KEY`. Set `ARK_BASE_URL` only when using a non-production + endpoint. +3. Start the worker with the MCP server command after `--`: + + ```bash + export ARK_API_KEY=... + export MA_ENVIRONMENT_ID=env_xxx + # Optional, for example when testing against staging: + # export ARK_BASE_URL=https://example.com/api/v3 + + JAR=examples/self_hosted_mcp_worker/target/ark-runtime-self-hosted-mcp-example-0.6.0-jar-with-dependencies.jar + java -jar "$JAR" -- java -cp "$JAR" \ + com.volcengine.ark.runtime.examples.selfhostedmcp.McpEchoServer + ``` + +4. Copy every printed `Agent custom tool: {...}` declaration into the Agent's + tool configuration. For the bundled server, use the declaration below. + Configure it before creating the Session; printing the declaration does not + update the Agent automatically. +5. Create a Session that uses both that Agent and the same self-hosted + Environment from `MA_ENVIRONMENT_ID`. +6. Send a message such as: + + ```text + Call mcp_echo exactly once with text "Hello from MCP echo!" and report the result. + ``` + +The verification passes when the Session shows an `mcp_echo` call with that +input, a `user.custom_tool_result` containing +`MCP echo: Hello from MCP echo!`, a final Agent response, and a final +`session.status_idle` whose stop reason is `end_turn`. A temporary +`session.status_idle` with stop reason `requires_action` means that the Session +is waiting for the external custom-tool result; it is expected and is not an +approval prompt or a failure. At the event level, observe these milestones: + +```text +agent.custom_tool_use +session.status_idle stop_reason=requires_action +user.custom_tool_result posted by the worker +agent.message +session.status_idle stop_reason=end_turn +``` + +Do not depend on the first idle event and the tool-result POST being displayed +in an exact relative order: the worker starts executing as soon as it observes +`agent.custom_tool_use`. + +Keep the worker process running for the whole verification. The command after +`--` is a stdio MCP server command, not a URL; the worker starts the process and +communicates with it through stdin/stdout. + +The bundled server exposes this declaration: + +```json +{ + "type": "custom", + "name": "mcp_echo", + "description": "Echo text through the local MCP server.", + "input_schema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"] + } +} +``` + +To use another stdio MCP server, replace the command after `--`. Set +`ARK_BASE_URL` only when overriding the SDK's production endpoint. The example +removes `ARK_API_KEY` from the MCP subprocess environment, but inherits other +environment variables. Review or allowlist them before production and use +separate MCP-specific credentials. + +The example opens one MCP process and client session for the lifetime of the +Environment Worker and reuses it for every Managed Agents Session handled by +that worker. MCP calls do not automatically contain the Managed Agents +`session_id` or `work_id`, and Session idle/deletion is not an MCP lifecycle +notification. Use a stateless MCP server or implement explicit tenant/session +isolation, and expect the MCP process to stop only when the worker exits. The +command-line example accepts a stdio child command only; other transports can +be used by constructing an MCP client session programmatically. + +Managed Agents currently accepts at most eight custom tools per Agent. If the +server exposes more, select the same stable subset for both the Agent and the +worker. Custom tools do not use Managed Agents permission policies: the worker +executes matching calls directly, so put approval, authorization, and operation +allowlists in the MCP server or wrapper. Only connect trusted servers, avoid +tool names that collide with built-in Agent tools, and configure an MCP client +timeout. MCP servers run with the worker's OS, filesystem, and network +permissions rather than in a Managed Agents sandbox, so run them with least +privilege and do not pass `ARK_API_KEY` to them. Update the Agent while it is +idle and restart the worker whenever the server's tool list changes. + +This directory is a separate Java 17 module so the optional MCP dependency does +not change the core SDK's Java 8 baseline. diff --git a/examples/self_hosted_mcp_worker/pom.xml b/examples/self_hosted_mcp_worker/pom.xml new file mode 100644 index 0000000..6c13c25 --- /dev/null +++ b/examples/self_hosted_mcp_worker/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.volcengine + ark-runtime-self-hosted-mcp-example + 0.6.0 + + + UTF-8 + 17 + 0.6.0 + 3.7.1 + 3.14.1 + + + + + com.volcengine + ark-runtime-mcp + ${ark-runtime.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.volcengine.ark.runtime.examples.selfhostedmcp.SelfHostedMcpWorkerExample + + + + jar-with-dependencies + + + + + assemble-example + package + + single + + + + + + + diff --git a/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/McpEchoServer.java b/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/McpEchoServer.java new file mode 100644 index 0000000..f1e5329 --- /dev/null +++ b/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/McpEchoServer.java @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.examples.selfhostedmcp; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +/** Minimal stdio MCP server used by {@link SelfHostedMcpWorkerExample}. */ +public final class McpEchoServer { + private McpEchoServer() { + } + + /** Starts a single echo tool over stdio. */ + public static void main(String[] args) throws InterruptedException { + Map textProperty = new LinkedHashMap<>(); + textProperty.put("type", "string"); + Map properties = new LinkedHashMap<>(); + properties.put("text", textProperty); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", Collections.singletonList("text")); + + StdioServerTransportProvider transport = + new StdioServerTransportProvider(McpJsonDefaults.getMapper()); + McpSyncServer server = McpServer.sync(transport) + .serverInfo("ark-self-hosted-mcp-example", "1.0.0") + .toolCall( + McpSchema.Tool.builder("mcp_echo", schema) + .description("Echo text through the local MCP server.") + .build(), + (exchange, request) -> McpSchema.CallToolResult.builder() + .addTextContent("MCP echo: " + request.arguments().get("text")) + .build()) + .build(); + Runtime.getRuntime().addShutdownHook(new Thread(server::close, "mcp-echo-server-shutdown")); + new CountDownLatch(1).await(); + } +} diff --git a/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/SelfHostedMcpWorkerExample.java b/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/SelfHostedMcpWorkerExample.java new file mode 100644 index 0000000..c5667c2 --- /dev/null +++ b/examples/self_hosted_mcp_worker/src/main/java/com/volcengine/ark/runtime/examples/selfhostedmcp/SelfHostedMcpWorkerExample.java @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.examples.selfhostedmcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.volcengine.ark.runtime.mcp.McpTools; +import com.volcengine.ark.runtime.models.agent.ToolItem; +import com.volcengine.ark.runtime.selfhosted.EnvironmentWorker; +import com.volcengine.ark.runtime.selfhosted.SelfHostedClient; +import com.volcengine.ark.runtime.service.ArkService; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Runs a self-hosted worker whose custom tools execute through a local MCP server. */ +public final class SelfHostedMcpWorkerExample { + private SelfHostedMcpWorkerExample() { + } + + /** Connects to MCP, prints Agent declarations, and starts the worker. */ + public static void main(String[] args) throws Exception { + String apiKey = requiredEnv("ARK_API_KEY"); + String environmentID = requiredEnv("MA_ENVIRONMENT_ID"); + List command = mcpCommand(args); + if (command.isEmpty()) { + throw new IllegalArgumentException( + "MCP server command is required; see examples/self_hosted_mcp_worker/README.md"); + } + + Map serverEnvironment = new LinkedHashMap<>(System.getenv()); + serverEnvironment.remove("ARK_API_KEY"); + ServerParameters parameters = ServerParameters.builder(command.get(0)) + .args(command.subList(1, command.size())) + .env(serverEnvironment) + .build(); + StdioClientTransport transport = new StdioClientTransport( + parameters, McpJsonDefaults.getMapper()); + + try (McpSyncClient mcpClient = McpClient.sync(transport) + .clientInfo(McpSchema.Implementation.builder( + "ark-self-hosted-worker-example", "1.0.0") + .build()) + .build()) { + mcpClient.initialize(); + List tools = listAllTools(mcpClient); + ObjectMapper mapper = ArkService.defaultObjectMapper(); + for (ToolItem declaration : McpTools.customToolItems(tools)) { + System.out.println("Agent custom tool: " + mapper.writeValueAsString(declaration)); + } + System.out.flush(); + + SelfHostedClient.Builder clientBuilder = new SelfHostedClient.Builder().apiKey(apiKey); + String baseURL = System.getenv("ARK_BASE_URL"); + if (baseURL != null && !baseURL.isEmpty()) { + clientBuilder.baseUrl(baseURL); + } + EnvironmentWorker worker = new EnvironmentWorker( + clientBuilder.build(), + new EnvironmentWorker.Options() + .environmentId(environmentID) + .workdir(".") + .customTools(McpTools.mcpTools(tools, mcpClient))); + Runtime.getRuntime().addShutdownHook( + new Thread(worker::close, "ark-self-hosted-mcp-worker-shutdown")); + try { + worker.run(); + } finally { + worker.close(); + } + } + } + + private static List listAllTools(McpSyncClient client) { + List tools = new ArrayList<>(); + String cursor = null; + do { + McpSchema.ListToolsResult page = cursor == null + ? client.listTools() + : client.listTools(cursor); + if (page.tools() != null) { + tools.addAll(page.tools()); + } + cursor = page.nextCursor(); + } while (cursor != null && !cursor.isEmpty()); + return tools; + } + + private static List mcpCommand(String[] args) { + List values = new ArrayList<>(Arrays.asList(args)); + if (!values.isEmpty() && "--".equals(values.get(0))) { + values.remove(0); + } + return values; + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " is required"); + } + return value; + } +} diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..94f9bce --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,106 @@ +# Ark Runtime MCP helpers + +See the runnable +[`self_hosted_mcp_worker`](../examples/self_hosted_mcp_worker) example for a +minimal stdio MCP server connected to an Ark self-hosted worker. + +This optional Java 17 module uses the official MCP Java SDK 2.0 and converts its tools into: + +- Ark Agent `custom` tool declarations; and +- runnable self-hosted worker tools. + +The MCP client remains inside the customer worker. Managed Agent receives only the tool +definition, each tool call, and the returned result; it does not connect to the MCP server. + +The adapter wraps an already connected `McpSyncClient`, so applications may use stdio or +another transport supported by their MCP client. Keep one client open for the worker lifetime. +That client is reused across all Managed Agents Sessions handled by the worker; calls do not +automatically include a Managed Agents `session_id` or `work_id`, and Session idle/deletion is +not an MCP lifecycle notification. Use stateless tools or implement explicit tenant/session +isolation in the MCP server. + +```java +List definitions = new ArrayList<>(); +String cursor = null; +do { + McpSchema.ListToolsResult page = cursor == null + ? mcpClient.listTools() + : mcpClient.listTools(cursor); + if (page.tools() != null) { + definitions.addAll(page.tools()); + } + cursor = page.nextCursor(); +} while (cursor != null && !cursor.isEmpty()); + +List agentTools = McpTools.customToolItems(definitions); +Map workerTools = McpTools.mcpTools(definitions, mcpClient); +``` + +Use `agentTools` when creating or updating the Agent, and pass `workerTools` through +`new EnvironmentWorker.Options().customTools(workerTools)`. Keep the initialized +`McpSyncClient` open for the entire worker lifetime. + +The core `ark-runtime` artifact remains compatible with Java 8. This optional module requires +Java 17 because the official MCP Java SDK requires Java 17. + +Use the same release version for the core SDK and this adapter: + +```xml + + com.volcengine + ark-runtime-mcp + 0.6.0 + +``` + +The core Java 8 artifact also exposes the protocol-independent +`com.volcengine.ark.runtime.selfhosted.mcp.McpClient` interface. Applications may implement +that interface directly when they use another MCP transport. + +The adapter requires the Jackson 2.20 release line or newer. Applications that manage Jackson +versions should pin `jackson-annotations`, `jackson-core`, and `jackson-databind` to a +compatible release line in their `dependencyManagement`; forcing older versions can cause +runtime linkage errors. + +When preparing a release, update both the adapter project version and the +`ark-runtime.version` property in `mcp/pom.xml` to match the core release tag. + +Managed Agents currently accepts the top-level JSON Schema fields `type`, `properties`, and +`required`. The helper keeps those fields structured, inlines local `$defs` and `definitions` +references used by properties, and appends other top-level constraints as compact JSON to the +tool description. The MCP server remains the authoritative validator when the worker executes +the call. Agent tool descriptions, including appended constraints, must fit within 10,000 +characters. + +## Tool result support + +The worker preserves MCP `isError` and supports text, `image/jpeg`, `image/png`, `image/gif`, +and `image/webp` image blocks. Embedded resources may contain the same image MIME types, +`application/pdf`, or text whose MIME type is absent, empty, or starts with `text/`. When a +result has no content blocks but has `structuredContent`, the helper serializes it as compact +JSON text. + +Audio, resource links, unknown content types, and other resource MIME types become an error +result. If a result mixes supported and unsupported blocks, the whole converted result is an +error; the supported blocks are not returned separately. + +## Operational and security notes + +- Fetch every `tools/list` page. Use the exact same selected tool definitions for the Agent + declaration and worker registry. Managed Agents currently accepts at most eight custom tools + per Agent, so explicitly select a stable subset when the MCP server exposes more. +- Tool discovery happens at worker startup. When the MCP server changes its tools, update the + Agent while it is idle and restart the worker. +- Tool names must match `[a-zA-Z0-9_-]{1,128}`. Avoid names that collide with built-in Agent + tools, and add your own prefixes when multiple MCP servers expose the same name. +- Managed Agents permission policies do not apply to custom tools. The worker executes each + matching call, so implement approval, authorization, and operation allowlists in the MCP + server or a wrapper tool. +- Client-side MCP servers run with the worker's OS, filesystem, and network permissions; + Managed Agents does not put them in a separate sandbox. Run them with least privilege and a + minimal environment. Do not pass `ARK_API_KEY` to an MCP subprocess; use separate + MCP-specific credentials. +- Only wrap MCP servers you trust. Tool names, descriptions, inputs, and results enter the model + context and must be treated as untrusted content. +- Configure MCP transport or client timeouts. The worker tool timeout remains the final upper + bound, but a shorter MCP timeout gives clearer failures. diff --git a/mcp/pom.xml b/mcp/pom.xml new file mode 100644 index 0000000..0cde82a --- /dev/null +++ b/mcp/pom.xml @@ -0,0 +1,189 @@ + + + 4.0.0 + + com.volcengine + ark-runtime-mcp + 0.6.0 + jar + + ark-runtime-mcp + Client-side MCP helpers for the Ark self-hosted worker + https://github.com/volcengine/ark-runtime-java + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + https://github.com/volcengine/ark-runtime-java + scm:git:https://github.com/volcengine/ark-runtime-java.git + scm:git:https://github.com/volcengine/ark-runtime-java.git + + + + volcengine + ark-runtime + volcengine@bytedance.com + + + + + UTF-8 + 17 + 0.6.0 + 2.0.0 + 2.20 + 2.20.0 + 4.13.2 + 5.20.0 + + + + + com.volcengine + ark-runtime + ${ark-runtime.version} + + + io.modelcontextprotocol.sdk + mcp + ${mcp.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.annotations.version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + junit + junit + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + + + ${project.basedir}/.. + META-INF + false + + LICENSE + THIRD_PARTY_NOTICES.md + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.apache.maven.plugins + maven-source-plugin + 3.1.0 + + + attach-sources + + jar-no-fork + + + + + + + + + + public + + + sonatype-nexus-staging + https://ossrh-staging-api.central.sonatype.com/content/repositories/snapshots + + + sonatype-nexus-staging + https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/ + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 + true + + central + true + true + + + + + + + diff --git a/mcp/src/main/java/com/volcengine/ark/runtime/mcp/McpTools.java b/mcp/src/main/java/com/volcengine/ark/runtime/mcp/McpTools.java new file mode 100644 index 0000000..fc3e66f --- /dev/null +++ b/mcp/src/main/java/com/volcengine/ark/runtime/mcp/McpTools.java @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.mcp; + +import com.volcengine.ark.runtime.models.agent.ToolItem; +import com.volcengine.ark.runtime.selfhosted.Tool; +import com.volcengine.ark.runtime.selfhosted.ToolContext; +import com.volcengine.ark.runtime.selfhosted.ToolResult; +import com.volcengine.ark.runtime.selfhosted.mcp.McpCallToolResult; +import com.volcengine.ark.runtime.selfhosted.mcp.McpClient; +import com.volcengine.ark.runtime.selfhosted.mcp.McpContent; +import com.volcengine.ark.runtime.selfhosted.mcp.McpResource; +import com.volcengine.ark.runtime.selfhosted.mcp.McpToolDefinition; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Adapts the official MCP Java SDK to the Ark self-hosted MCP contract. */ +public final class McpTools { + private McpTools() { + } + + /** Converts an official MCP tool definition into an Ark Agent declaration. */ + public static ToolItem customToolItem(McpSchema.Tool tool) { + return com.volcengine.ark.runtime.selfhosted.mcp.McpTools.customToolItem( + toolDefinition(tool)); + } + + /** Converts official MCP tool definitions into Ark Agent declarations. */ + public static List customToolItems(List tools) { + return com.volcengine.ark.runtime.selfhosted.mcp.McpTools.customToolItems( + toolDefinitions(tools)); + } + + /** Wraps an official MCP tool as a self-hosted worker custom tool. */ + public static Tool mcpTool(McpSchema.Tool tool, McpSyncClient client) { + return com.volcengine.ark.runtime.selfhosted.mcp.McpTools.mcpTool( + toolDefinition(tool), + new OfficialMcpClient(client)); + } + + /** Wraps official MCP tools for EnvironmentWorkerOptions.customTools. */ + public static Map mcpTools(List tools, McpSyncClient client) { + return com.volcengine.ark.runtime.selfhosted.mcp.McpTools.mcpTools( + toolDefinitions(tools), + new OfficialMcpClient(client)); + } + + /** Converts an official MCP result into the result posted by the worker. */ + public static ToolResult toolResult(McpSchema.CallToolResult result) { + return com.volcengine.ark.runtime.selfhosted.mcp.McpTools.toolResult( + callToolResult(result)); + } + + private static List toolDefinitions(List tools) { + if (tools == null) { + return Collections.emptyList(); + } + List definitions = new ArrayList(tools.size()); + for (McpSchema.Tool tool : tools) { + definitions.add(toolDefinition(tool)); + } + return definitions; + } + + private static McpToolDefinition toolDefinition(McpSchema.Tool tool) { + if (tool == null) { + throw new IllegalArgumentException("mcp tool is required"); + } + return new McpToolDefinition( + tool.name(), + tool.description(), + inputSchema(tool.inputSchema())); + } + + private static Map inputSchema(Map schema) { + if (schema == null) { + return Collections.singletonMap("type", "object"); + } + return new LinkedHashMap(schema); + } + + private static McpCallToolResult callToolResult(McpSchema.CallToolResult result) { + if (result == null) { + return null; + } + List content = new ArrayList(); + if (result.content() != null) { + for (McpSchema.Content item : result.content()) { + content.add(content(item)); + } + } + return new McpCallToolResult( + content, + result.structuredContent(), + Boolean.TRUE.equals(result.isError())); + } + + private static McpContent content(McpSchema.Content content) { + if (content instanceof McpSchema.TextContent) { + return new McpContent( + "text", ((McpSchema.TextContent) content).text(), null, null, null); + } + if (content instanceof McpSchema.ImageContent) { + McpSchema.ImageContent image = (McpSchema.ImageContent) content; + return new McpContent("image", null, image.mimeType(), image.data(), null); + } + if (content instanceof McpSchema.EmbeddedResource) { + return new McpContent( + "resource", + null, + null, + null, + resource(((McpSchema.EmbeddedResource) content).resource())); + } + if (content instanceof McpSchema.AudioContent) { + return new McpContent("audio", null, null, null, null); + } + if (content instanceof McpSchema.ResourceLink) { + return new McpContent("resource_link", null, null, null, null); + } + return new McpContent(content == null ? "null" : content.type(), null, null, null, null); + } + + private static McpResource resource(McpSchema.ResourceContents resource) { + if (resource == null) { + return null; + } + String text = null; + String blob = null; + if (resource instanceof McpSchema.TextResourceContents) { + text = ((McpSchema.TextResourceContents) resource).text(); + } else if (resource instanceof McpSchema.BlobResourceContents) { + blob = ((McpSchema.BlobResourceContents) resource).blob(); + } + return new McpResource(resource.uri(), resource.mimeType(), text, blob); + } + + private static final class OfficialMcpClient implements McpClient { + private final McpSyncClient client; + + private OfficialMcpClient(McpSyncClient client) { + if (client == null) { + throw new IllegalArgumentException("mcp client is required"); + } + this.client = client; + } + + @Override + public McpCallToolResult callTool( + String name, + Map arguments, + ToolContext context) { + if (context != null && context.isCancelled()) { + throw new IllegalStateException("execution cancelled"); + } + return McpTools.callToolResult( + client.callTool(McpSchema.CallToolRequest.builder(name) + .arguments(arguments) + .build())); + } + } +} diff --git a/mcp/src/test/java/com/volcengine/ark/runtime/mcp/McpToolsTest.java b/mcp/src/test/java/com/volcengine/ark/runtime/mcp/McpToolsTest.java new file mode 100644 index 0000000..af3908b --- /dev/null +++ b/mcp/src/test/java/com/volcengine/ark/runtime/mcp/McpToolsTest.java @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.mcp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.volcengine.ark.runtime.models.agent.ToolItem; +import com.volcengine.ark.runtime.selfhosted.ContentBlock; +import com.volcengine.ark.runtime.selfhosted.Tool; +import com.volcengine.ark.runtime.selfhosted.ToolContext; +import com.volcengine.ark.runtime.selfhosted.ToolResult; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class McpToolsTest { + @Test + public void officialMapperSerializesProtocolValuesWithResolvedAnnotations() throws Exception { + String value = McpJsonDefaults.getMapper().writeValueAsString( + McpSchema.CallToolRequest.builder("echo") + .arguments(Collections.singletonMap("text", "hello")) + .build()); + + assertTrue(value.contains("\"name\":\"echo\"")); + assertTrue(value.contains("\"text\":\"hello\"")); + } + + @Test + public void customToolItemAdaptsSchemaToCurrentAgentContract() { + Map properties = new LinkedHashMap<>(); + properties.put("city", Collections.singletonMap("type", "string")); + Map definitions = Collections.singletonMap( + "city", Collections.singletonMap("minLength", 1)); + Map conditionalSchema = Collections.singletonMap( + "if", Collections.singletonMap("required", Collections.singletonList("country"))); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", Collections.singletonList("city")); + schema.put("additionalProperties", false); + schema.put("$defs", definitions); + schema.put("allOf", Collections.singletonList(conditionalSchema)); + schema.put("oneOf", Collections.singletonList( + Collections.singletonMap("required", Collections.singletonList("city")))); + schema.put("anyOf", Collections.singletonList( + Collections.singletonMap("required", Collections.singletonList("country")))); + schema.put("patternProperties", Collections.singletonMap("^x-", Collections.singletonMap("type", "string"))); + schema.put("dependentSchemas", Collections.singletonMap("country", conditionalSchema)); + schema.put("unevaluatedProperties", false); + McpSchema.Tool tool = McpSchema.Tool.builder("weather", schema) + .description("Get weather") + .build(); + + ToolItem item = McpTools.customToolItem(tool); + + assertEquals("custom", item.getType()); + assertEquals("weather", item.getName()); + assertTrue(item.getDescription().startsWith("Get weather")); + assertEquals("object", item.getInputSchema().getType()); + assertEquals(properties, item.getInputSchema().getProperties()); + assertEquals(Collections.singletonList("city"), item.getInputSchema().getRequired()); + assertTrue(item.getDescription().contains("\"additionalProperties\":false")); + assertFalse(item.getDescription().contains("\"$defs\"")); + assertTrue(item.getDescription().contains("\"allOf\"")); + assertTrue(item.getDescription().contains("\"oneOf\"")); + assertTrue(item.getDescription().contains("\"anyOf\"")); + assertTrue(item.getDescription().contains("\"patternProperties\"")); + assertTrue(item.getDescription().contains("\"dependentSchemas\"")); + assertTrue(item.getDescription().contains("\"unevaluatedProperties\":false")); + } + + @Test + public void mcpToolExecutesClientAndConvertsTextResult() { + McpSchema.Tool definition = tool("weather"); + McpSyncClient client = mock(McpSyncClient.class); + when(client.callTool(any(McpSchema.CallToolRequest.class))).thenReturn( + new McpSchema.CallToolResult( + Collections.singletonList(McpSchema.TextContent.builder("sunny").build()), + false, + null, + null)); + Tool tool = McpTools.mcpTool(definition, client); + + ToolResult result = tool.execute( + Collections.singletonMap("city", "Beijing"), new ToolContext(".")); + + assertFalse(result.isError()); + assertEquals("sunny", result.getContent().get(0).getText()); + } + + @Test + public void toolResultPreservesImageAndDocumentSources() { + String imageData = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3}); + String textData = Base64.getEncoder().encodeToString( + "hello".getBytes(StandardCharsets.UTF_8)); + List content = List.of( + McpSchema.ImageContent.builder(imageData, "image/png").build(), + McpSchema.EmbeddedResource.builder( + McpSchema.BlobResourceContents.builder("file:///note.txt", textData) + .mimeType("text/plain") + .build()) + .build()); + + ToolResult result = McpTools.toolResult( + new McpSchema.CallToolResult(content, false, null, null)); + + assertFalse(result.isError()); + assertEquals("image", result.getContent().get(0).getType()); + assertEquals(imageData, source(result.getContent().get(0)).get("data")); + assertEquals("document", result.getContent().get(1).getType()); + assertEquals("hello", source(result.getContent().get(1)).get("data")); + } + + @Test + public void unsupportedContentBecomesErrorResult() { + McpSchema.Content audio = mock(McpSchema.AudioContent.class); + + ToolResult result = McpTools.toolResult(new McpSchema.CallToolResult( + Collections.singletonList(audio), false, null, null)); + + assertTrue(result.isError()); + assertTrue(result.getContent().get(0).getText().contains("audio")); + } + + @Test + public void structuredContentBecomesJsonText() { + ToolResult result = McpTools.toolResult(new McpSchema.CallToolResult( + Collections.emptyList(), + false, + Collections.singletonMap("ok", true), + null)); + + assertFalse(result.isError()); + assertEquals("{\"ok\":true}", result.getContent().get(0).getText()); + } + + @SuppressWarnings("unchecked") + private static Map source(ContentBlock block) { + return (Map) block.getSource(); + } + + private static McpSchema.Tool tool(String name) { + return McpSchema.Tool.builder( + name, + Collections.singletonMap("type", "object")) + .build(); + } +} diff --git a/pom.xml b/pom.xml index c0f8c8a..cbbc5ef 100644 --- a/pom.xml +++ b/pom.xml @@ -36,9 +36,8 @@ UTF-8 - 8 - 8 - 3.1 + 8 + 3.14.1 3.1.0 3.2.0 @@ -148,8 +147,7 @@ maven-compiler-plugin ${dep.maven-compiler-plugin.version} - ${java.source.version} - ${java.target.version} + ${java.release.version} ${project.build.sourceEncoding} diff --git a/src/main/java/com/volcengine/ark/runtime/Const.java b/src/main/java/com/volcengine/ark/runtime/Const.java index 6a01cc7..2e28d3f 100644 --- a/src/main/java/com/volcengine/ark/runtime/Const.java +++ b/src/main/java/com/volcengine/ark/runtime/Const.java @@ -12,6 +12,9 @@ public class Const { public static final String REQUEST_MODEL = "X-Request-Model"; public static final String REQUEST_PROJECT_NAME = "X-Project-Name"; public static final String RETRY_AFTER = "Retry-After"; + public static final String RETRY_AFTER_MS = "Retry-After-Ms"; + public static final String RETRY_COUNT_HEADER = "X-Stainless-Retry-Count"; + public static final String SHOULD_RETRY_HEADER = "X-Should-Retry"; public static final Integer DEFAULT_MANDATORY_REFRESH_TIMEOUT = 10 * 60; // 10 min public static final Integer DEFAULT_ADVISORY_REFRESH_TIMEOUT = 30 * 60; // 30 min public static final Integer DEFAULT_STS_TIMEOUT = 7 * 24 * 60 * 60; // 7 days diff --git a/src/main/java/com/volcengine/ark/runtime/interceptor/RetryInterceptor.java b/src/main/java/com/volcengine/ark/runtime/interceptor/RetryInterceptor.java index 42e4553..7aaa538 100644 --- a/src/main/java/com/volcengine/ark/runtime/interceptor/RetryInterceptor.java +++ b/src/main/java/com/volcengine/ark/runtime/interceptor/RetryInterceptor.java @@ -6,72 +6,172 @@ import static com.volcengine.ark.runtime.Const.*; import static java.lang.Math.random; +import java.io.IOException; import java.io.InterruptedIOException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.concurrent.TimeUnit; import okhttp3.Interceptor; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; public class RetryInterceptor implements Interceptor { private final int retryTimes; - private final double INITIAL_RETRY_DELAY = 0.5; - private final double MAX_RETRY_DELAY = 8.0; + private static final double INITIAL_RETRY_DELAY = 0.5; + private static final double MAX_RETRY_DELAY = 8.0; + private static final Duration MAX_SERVER_RETRY_DELAY = Duration.ofSeconds(60); public RetryInterceptor(int retryTimes) { this.retryTimes = retryTimes; } @Override - public Response intercept(Chain chain) throws RuntimeException, InterruptedIOException { + public Response intercept(Chain chain) throws IOException { Request request = chain.request(); int requestRetryTimes = getRetryTimes(request); + boolean shouldSendRetryCount = request.header(RETRY_COUNT_HEADER) == null; Response response = null; - int tryCount = 0; + int retryCount = 0; boolean shouldRetry; - Exception exception; + IOException exception; do { if (response != null) { response.close(); + response = null; } exception = null; try { - response = chain.proceed(request); - shouldRetry = response.code() >= 500 || response.code() == 429; - } catch (Exception e) { + Request attempt = shouldSendRetryCount + ? request.newBuilder().header(RETRY_COUNT_HEADER, Integer.toString(retryCount)).build() + : request; + response = chain.proceed(attempt); + shouldRetry = shouldRetry(response); + } catch (IOException e) { shouldRetry = true; exception = e; } - tryCount++; - if (!(shouldRetry && tryCount <= requestRetryTimes)) { + if (!(shouldRetry && retryCount < requestRetryTimes && isRetryable(request))) { break; } try { - double interval = retryInterval(requestRetryTimes, requestRetryTimes - tryCount) * 1000; - Thread.sleep(Math.round(interval)); + Duration delay = retryDelay(response, retryCount); + if (response != null) { + response.close(); + response = null; + } + sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new InterruptedIOException(); } + retryCount++; } while (true); if (response != null) { return response; } - throw new RuntimeException(exception); + throw exception; } + /** + * Returns the legacy retry interval retained for source compatibility. + * Production requests use the response-aware retry delay instead. + * + * @deprecated This compatibility method is not used by the interceptor retry loop. + */ + @Deprecated public double retryInterval(int max, int remain) { - double nbRetries = Math.min(max - remain, MAX_RETRY_DELAY/INITIAL_RETRY_DELAY); + double nbRetries = Math.min(max - remain, MAX_RETRY_DELAY / INITIAL_RETRY_DELAY); double sleepSeconds = Math.min(INITIAL_RETRY_DELAY * Math.pow(2.0, nbRetries), MAX_RETRY_DELAY); double jitter = 1 - 0.25 * random(); return sleepSeconds * jitter; } + protected void sleep(Duration duration) throws InterruptedException { + long millis = duration.toMillis(); + int nanos = (int) (duration.minusMillis(millis).toNanos()); + Thread.sleep(millis, nanos); + } + + private boolean shouldRetry(Response response) { + String shouldRetry = response.header(SHOULD_RETRY_HEADER); + if ("true".equalsIgnoreCase(shouldRetry)) { + return true; + } + if ("false".equalsIgnoreCase(shouldRetry)) { + return false; + } + int statusCode = response.code(); + return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500; + } + + private boolean isRetryable(Request request) { + RequestBody body = request.body(); + return body == null || (!body.isOneShot() && !body.isDuplex()); + } + + private Duration retryDelay(Response response, int retryCount) { + Duration retryAfter = parseRetryAfter(response); + if (retryAfter != null && retryAfter.compareTo(Duration.ZERO) > 0 + && retryAfter.compareTo(MAX_SERVER_RETRY_DELAY) <= 0) { + return retryAfter; + } + double seconds = Math.min(INITIAL_RETRY_DELAY * Math.pow(2.0, retryCount), MAX_RETRY_DELAY); + double jitter = 1 - 0.25 * random(); + return Duration.ofNanos((long) (TimeUnit.SECONDS.toNanos(1) * seconds * jitter)); + } + + private Duration parseRetryAfter(Response response) { + if (response == null) { + return null; + } + Duration milliseconds = parseNumericDuration(response.header(RETRY_AFTER_MS), TimeUnit.MILLISECONDS); + if (milliseconds != null) { + return milliseconds; + } + String retryAfter = response.header(RETRY_AFTER); + Duration seconds = parseNumericDuration(retryAfter, TimeUnit.SECONDS); + if (seconds != null) { + return seconds; + } + if (retryAfter == null) { + return null; + } + try { + Instant retryAt = ZonedDateTime.parse(retryAfter, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return Duration.between(Instant.now(), retryAt); + } catch (DateTimeParseException ignored) { + return null; + } + } + + private Duration parseNumericDuration(String value, TimeUnit unit) { + if (value == null) { + return null; + } + try { + double parsed = Double.parseDouble(value); + double nanos = parsed * unit.toNanos(1); + if (!Double.isFinite(parsed) || !Double.isFinite(nanos) + || nanos > Long.MAX_VALUE || nanos < Long.MIN_VALUE) { + return null; + } + return Duration.ofNanos((long) nanos); + } catch (NumberFormatException ignored) { + return null; + } + } + public int getRetryTimes(Request request) { String path = request.url().encodedPath(); if (path.startsWith(BATCH_PATH_PREFIX)) { @@ -80,4 +180,3 @@ public int getRetryTimes(Request request) { return retryTimes; } } - diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/ContentBlock.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/ContentBlock.java index feeb93a..cae9380 100644 --- a/src/main/java/com/volcengine/ark/runtime/selfhosted/ContentBlock.java +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/ContentBlock.java @@ -11,6 +11,9 @@ public class ContentBlock { private String text; private String mediaType; private Object data; + private Object source; + private String title; + private String context; public ContentBlock() { } @@ -23,8 +26,8 @@ public ContentBlock(String type, String text) { public Map toMap() { Map out = new LinkedHashMap<>(); out.put("type", type); - if (text != null && !text.isEmpty()) { - out.put("text", text); + if ("text".equals(type) || (text != null && !text.isEmpty())) { + out.put("text", text == null ? "" : text); } if (mediaType != null && !mediaType.isEmpty()) { out.put("media_type", mediaType); @@ -32,6 +35,15 @@ public Map toMap() { if (data != null) { out.put("data", data); } + if (source != null) { + out.put("source", source); + } + if (title != null && !title.isEmpty()) { + out.put("title", title); + } + if (context != null && !context.isEmpty()) { + out.put("context", context); + } return out; } @@ -66,4 +78,28 @@ public Object getData() { public void setData(Object data) { this.data = data; } + + public Object getSource() { + return source; + } + + public void setSource(Object source) { + this.source = source; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getContext() { + return context; + } + + public void setContext(String context) { + this.context = context; + } } diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorker.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorker.java index 9525236..a798f5e 100644 --- a/src/main/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorker.java +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorker.java @@ -7,13 +7,13 @@ import com.volcengine.ark.runtime.models.environment.WorkItem; import com.volcengine.ark.runtime.models.environment.WorkState; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.LinkedHashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -305,14 +305,16 @@ private static void sleep(long millis, AtomicBoolean stop) { static String defaultWorkerId() { try { - String runtimeName = ManagementFactory.getRuntimeMXBean().getName(); - String pid = runtimeName == null ? "" : runtimeName.split("@")[0]; - return InetAddress.getLocalHost().getHostName() + "-" + pid; + return InetAddress.getLocalHost().getHostName() + "-" + workerIDSuffix(); } catch (Throwable ignored) { - return "worker-" + System.currentTimeMillis(); + return "worker-" + workerIDSuffix(); } } + private static String workerIDSuffix() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + @Override public void close() { if (!closed.compareAndSet(false, true)) { diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/Event.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/Event.java index 9e500f4..b0ff6bc 100644 --- a/src/main/java/com/volcengine/ark/runtime/selfhosted/Event.java +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/Event.java @@ -58,6 +58,9 @@ public static Event fromMap(Map raw) { contentBlock.setText(stringValue(blockMap.get("text"))); contentBlock.setMediaType(stringValue(blockMap.get("media_type"))); contentBlock.setData(blockMap.get("data")); + contentBlock.setSource(blockMap.get("source")); + contentBlock.setTitle(stringValue(blockMap.get("title"))); + contentBlock.setContext(stringValue(blockMap.get("context"))); event.content.add(contentBlock); } } diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClient.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClient.java index 40bc205..d1cdd97 100644 --- a/src/main/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClient.java +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClient.java @@ -84,6 +84,7 @@ private SelfHostedClient(Builder builder) { .writeTimeout(HEARTBEAT_TIMEOUT_SECONDS, TimeUnit.SECONDS) .callTimeout(HEARTBEAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); heartbeatClientBuilder.interceptors().removeIf(interceptor -> interceptor instanceof RetryInterceptor); + heartbeatClientBuilder.interceptors().add(0, new RetryInterceptor(0)); Retrofit heartbeatRetrofit = ArkService.defaultRetrofit( heartbeatClientBuilder.build(), this.mapper, normalizeBaseUrl(builder.baseUrl), null); this.heartbeatApi = heartbeatRetrofit.create(ArkApi.class); @@ -94,6 +95,7 @@ private SelfHostedClient(Builder builder) { .writeTimeout(LIFECYCLE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .callTimeout(LIFECYCLE_TIMEOUT_SECONDS, TimeUnit.SECONDS); lifecycleClientBuilder.interceptors().removeIf(interceptor -> interceptor instanceof RetryInterceptor); + lifecycleClientBuilder.interceptors().add(0, new RetryInterceptor(0)); Retrofit lifecycleRetrofit = ArkService.defaultRetrofit( lifecycleClientBuilder.build(), this.mapper, normalizeBaseUrl(builder.baseUrl), null); this.lifecycleApi = lifecycleRetrofit.create(ArkApi.class); diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpCallToolResult.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpCallToolResult.java new file mode 100644 index 0000000..437ca23 --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpCallToolResult.java @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +import java.util.ArrayList; +import java.util.List; + +/** Protocol-independent MCP tool result. */ +public class McpCallToolResult { + private final List content; + private final Object structuredContent; + private final boolean error; + + public McpCallToolResult( + List content, + Object structuredContent, + boolean error) { + this.content = content == null ? new ArrayList() : content; + this.structuredContent = structuredContent; + this.error = error; + } + + public List getContent() { + return content; + } + + public Object getStructuredContent() { + return structuredContent; + } + + public boolean isError() { + return error; + } +} diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpClient.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpClient.java new file mode 100644 index 0000000..a13c4b4 --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpClient.java @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +import com.volcengine.ark.runtime.selfhosted.ToolContext; + +import java.util.Map; + +/** Minimal MCP client contract required by a self-hosted worker. */ +public interface McpClient { + McpCallToolResult callTool( + String name, + Map arguments, + ToolContext context) throws Exception; +} diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpContent.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpContent.java new file mode 100644 index 0000000..e50f611 --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpContent.java @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +/** Protocol-independent MCP content block. */ +public class McpContent { + private final String type; + private final String text; + private final String mimeType; + private final String data; + private final McpResource resource; + + public McpContent( + String type, + String text, + String mimeType, + String data, + McpResource resource) { + this.type = type; + this.text = text; + this.mimeType = mimeType; + this.data = data; + this.resource = resource; + } + + public String getType() { + return type; + } + + public String getText() { + return text; + } + + public String getMimeType() { + return mimeType; + } + + public String getData() { + return data; + } + + public McpResource getResource() { + return resource; + } +} diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpResource.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpResource.java new file mode 100644 index 0000000..2b01c7d --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpResource.java @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +/** Protocol-independent MCP embedded resource. */ +public class McpResource { + private final String uri; + private final String mimeType; + private final String text; + private final String blob; + + public McpResource(String uri, String mimeType, String text, String blob) { + this.uri = uri; + this.mimeType = mimeType; + this.text = text; + this.blob = blob; + } + + public String getUri() { + return uri; + } + + public String getMimeType() { + return mimeType; + } + + public String getText() { + return text; + } + + public String getBlob() { + return blob; + } +} diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolDefinition.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolDefinition.java new file mode 100644 index 0000000..5bed4ee --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolDefinition.java @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +import java.util.Map; + +/** Protocol-independent MCP tool definition. */ +public class McpToolDefinition { + private final String name; + private final String description; + private final Map inputSchema; + + public McpToolDefinition( + String name, + String description, + Map inputSchema) { + this.name = name; + this.description = description; + this.inputSchema = inputSchema; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Map getInputSchema() { + return inputSchema; + } +} diff --git a/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpTools.java b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpTools.java new file mode 100644 index 0000000..e71af91 --- /dev/null +++ b/src/main/java/com/volcengine/ark/runtime/selfhosted/mcp/McpTools.java @@ -0,0 +1,483 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.volcengine.ark.runtime.models.agent.CustomToolInputSchema; +import com.volcengine.ark.runtime.models.agent.ToolItem; +import com.volcengine.ark.runtime.selfhosted.ContentBlock; +import com.volcengine.ark.runtime.selfhosted.Tool; +import com.volcengine.ark.runtime.selfhosted.ToolContext; +import com.volcengine.ark.runtime.selfhosted.ToolResult; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Converts protocol-independent MCP values into Ark Agent and worker values. */ +public final class McpTools { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper SCHEMA_OBJECT_MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + private static final String COMPATIBILITY_DESCRIPTION_PREFIX = + "\n\nMCP input constraints (JSON Schema): "; + private static final int MAX_CUSTOM_TOOL_DESCRIPTION_CHARS = 10_000; + private static final Set SUPPORTED_IMAGE_MIME_TYPES; + private static final Set IGNORED_TOP_LEVEL_SCHEMA_KEYWORDS; + + static { + Set values = new HashSet(); + values.add("image/gif"); + values.add("image/jpeg"); + values.add("image/png"); + values.add("image/webp"); + SUPPORTED_IMAGE_MIME_TYPES = Collections.unmodifiableSet(values); + + Set ignoredKeywords = new HashSet(); + ignoredKeywords.add("$anchor"); + ignoredKeywords.add("$comment"); + ignoredKeywords.add("$dynamicAnchor"); + ignoredKeywords.add("$id"); + ignoredKeywords.add("$schema"); + ignoredKeywords.add("title"); + IGNORED_TOP_LEVEL_SCHEMA_KEYWORDS = Collections.unmodifiableSet(ignoredKeywords); + } + + private McpTools() { + } + + /** Converts an MCP tool definition into an Ark Agent custom tool declaration. */ + public static ToolItem customToolItem(McpToolDefinition tool) { + requireTool(tool); + SchemaConversion conversion = customToolInputSchema(tool.getInputSchema()); + String description = tool.getDescription(); + if (description == null || description.isEmpty()) { + description = tool.getName(); + } + if (!conversion.constraints.isEmpty()) { + description += COMPATIBILITY_DESCRIPTION_PREFIX + conversion.constraints; + } + if (description.codePointCount(0, description.length()) > MAX_CUSTOM_TOOL_DESCRIPTION_CHARS) { + throw new IllegalArgumentException( + "mcp tool " + tool.getName() + " description exceeds " + + MAX_CUSTOM_TOOL_DESCRIPTION_CHARS + + " characters after adding input constraints"); + } + return new ToolItem() + .type("custom") + .name(tool.getName()) + .description(description) + .inputSchema(conversion.schema); + } + + /** Converts MCP tool definitions into Ark Agent custom tool declarations. */ + public static List customToolItems(List tools) { + if (tools == null) { + return Collections.emptyList(); + } + Map items = new LinkedHashMap(); + for (McpToolDefinition tool : tools) { + ToolItem item = customToolItem(tool); + if (items.containsKey(tool.getName())) { + throw new IllegalArgumentException("duplicate mcp tool name: " + tool.getName()); + } + items.put(tool.getName(), item); + } + return new ArrayList(items.values()); + } + + /** Wraps an MCP tool as a self-hosted worker custom tool. */ + public static Tool mcpTool(McpToolDefinition tool, McpClient client) { + requireTool(tool); + if (client == null) { + throw new IllegalArgumentException("mcp client is required"); + } + return new GenericMcpTool(tool.getName(), client); + } + + /** Wraps MCP tools for EnvironmentWorkerOptions.customTools. */ + public static Map mcpTools(List tools, McpClient client) { + if (tools == null) { + return Collections.emptyMap(); + } + Map wrapped = new LinkedHashMap(); + for (McpToolDefinition tool : tools) { + Tool value = mcpTool(tool, client); + if (wrapped.containsKey(value.name())) { + throw new IllegalArgumentException("duplicate mcp tool name: " + value.name()); + } + wrapped.put(value.name(), value); + } + return wrapped; + } + + /** Converts a protocol-independent MCP call result into a worker result. */ + public static ToolResult toolResult(McpCallToolResult result) { + if (result == null) { + return ToolResult.error("mcp tool returned no result"); + } + List blocks = new ArrayList(); + try { + for (McpContent content : result.getContent()) { + blocks.add(contentBlock(content)); + } + } catch (IllegalArgumentException exception) { + return ToolResult.error(exception.getMessage()); + } + if (blocks.isEmpty() && result.getStructuredContent() != null) { + try { + blocks.add(new ContentBlock( + "text", OBJECT_MAPPER.writeValueAsString(result.getStructuredContent()))); + } catch (JsonProcessingException exception) { + return ToolResult.error("serialize mcp structured content: " + exception.getMessage()); + } + } + if (blocks.isEmpty() && result.isError()) { + blocks.add(new ContentBlock("text", "tool returned an error")); + } + return new ToolResult(blocks, result.isError()); + } + + private static void requireTool(McpToolDefinition tool) { + if (tool == null) { + throw new IllegalArgumentException("mcp tool is required"); + } + if (tool.getName() == null || tool.getName().isEmpty()) { + throw new IllegalArgumentException("mcp tool name is required"); + } + } + + private static SchemaConversion customToolInputSchema(Map schema) { + Map raw = schema == null + ? new LinkedHashMap() + : new LinkedHashMap(schema); + Object rawType = raw.remove("type"); + if (rawType != null && !(rawType instanceof String)) { + throw new IllegalArgumentException("mcp tool input schema type must be a string"); + } + String type = rawType == null ? "object" : (String) rawType; + if (!"object".equals(type)) { + throw new IllegalArgumentException("mcp tool input schema top-level type must be object"); + } + Object properties = raw.remove("properties"); + if (properties != null && !(properties instanceof Map)) { + throw new IllegalArgumentException("mcp tool input schema properties must be an object"); + } + Object required = raw.remove("required"); + if (required != null && !(required instanceof List)) { + throw new IllegalArgumentException("mcp tool input schema required must be an array"); + } + + Map constraints = new LinkedHashMap(); + for (Map.Entry entry : raw.entrySet()) { + if (!IGNORED_TOP_LEVEL_SCHEMA_KEYWORDS.contains(entry.getKey())) { + constraints.put(entry.getKey(), entry.getValue()); + } + } + + ResolveResult resolvedProperties = properties == null + ? new ResolveResult(null, false) + : resolveSchemaValue(properties, schema, new HashSet()); + if (resolvedProperties.unresolved) { + constraints.put("properties", properties); + } + removeUnreferencedDefinitions(constraints); + + CustomToolInputSchema converted = new CustomToolInputSchema().type(type); + if (properties != null) { + @SuppressWarnings("unchecked") + Map propertyMap = (Map) resolvedProperties.value; + converted.properties(propertyMap); + } + if (required != null) { + List names = new ArrayList(); + for (Object name : (List) required) { + if (!(name instanceof String)) { + throw new IllegalArgumentException( + "mcp tool input schema required must be an array of strings"); + } + names.add((String) name); + } + converted.required(names); + } + try { + String constraintsJSON = constraints.isEmpty() + ? "" + : SCHEMA_OBJECT_MAPPER.writeValueAsString(constraints); + return new SchemaConversion(converted, constraintsJSON); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException( + "mcp tool input schema constraints are not JSON serializable", exception); + } + } + + private static void removeUnreferencedDefinitions(Map constraints) { + Map definitionReferences = new LinkedHashMap(); + definitionReferences.put("$defs", "#/$defs"); + definitionReferences.put("definitions", "#/definitions"); + for (Map.Entry definition : definitionReferences.entrySet()) { + if (!constraints.containsKey(definition.getKey())) { + continue; + } + boolean referenced = false; + for (Map.Entry constraint : constraints.entrySet()) { + if (!definition.getKey().equals(constraint.getKey()) + && containsReference(constraint.getValue(), definition.getValue())) { + referenced = true; + break; + } + } + if (!referenced) { + constraints.remove(definition.getKey()); + } + } + } + + private static boolean containsReference(Object value, String prefix) { + if (value instanceof Map) { + Map object = (Map) value; + Object reference = object.get("$ref"); + if (reference instanceof String + && (reference.equals(prefix) || ((String) reference).startsWith(prefix + "/"))) { + return true; + } + for (Object item : object.values()) { + if (containsReference(item, prefix)) { + return true; + } + } + } else if (value instanceof List) { + for (Object item : (List) value) { + if (containsReference(item, prefix)) { + return true; + } + } + } + return false; + } + + private static ResolveResult resolveSchemaValue( + Object value, + Map root, + Set resolving) { + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map object = (Map) value; + Object rawReference = object.get("$ref"); + if (rawReference instanceof String) { + String reference = (String) rawReference; + Object target = resolveJSONPointer(root, reference); + if (target != null && !resolving.contains(reference)) { + resolving.add(reference); + ResolveResult resolvedTarget = resolveSchemaValue(target, root, resolving); + resolving.remove(reference); + if (resolvedTarget.value instanceof Map) { + @SuppressWarnings("unchecked") + Map targetMap = + (Map) resolvedTarget.value; + Map merged = new LinkedHashMap(targetMap); + for (Map.Entry entry : object.entrySet()) { + if (!"$ref".equals(entry.getKey())) { + merged.put(entry.getKey(), entry.getValue()); + } + } + ResolveResult resolved = resolveSchemaValue(merged, root, resolving); + return new ResolveResult( + resolved.value, + resolvedTarget.unresolved || resolved.unresolved); + } + } + return mapWithoutReference(object, root, resolving); + } + + Map result = new LinkedHashMap(); + boolean unresolved = false; + for (Map.Entry entry : object.entrySet()) { + ResolveResult resolved = resolveSchemaValue(entry.getValue(), root, resolving); + result.put(entry.getKey(), resolved.value); + unresolved = unresolved || resolved.unresolved; + } + return new ResolveResult(result, unresolved); + } + if (value instanceof List) { + List result = new ArrayList(); + boolean unresolved = false; + for (Object item : (List) value) { + ResolveResult resolved = resolveSchemaValue(item, root, resolving); + result.add(resolved.value); + unresolved = unresolved || resolved.unresolved; + } + return new ResolveResult(result, unresolved); + } + return new ResolveResult(value, false); + } + + private static ResolveResult mapWithoutReference( + Map value, + Map root, + Set resolving) { + Map result = new LinkedHashMap(); + for (Map.Entry entry : value.entrySet()) { + if ("$ref".equals(entry.getKey())) { + continue; + } + ResolveResult resolved = resolveSchemaValue(entry.getValue(), root, resolving); + result.put(entry.getKey(), resolved.value); + } + return new ResolveResult(result, true); + } + + private static Object resolveJSONPointer(Map root, String reference) { + if (!reference.startsWith("#/")) { + return null; + } + Object current = root; + String[] tokens = reference.substring(2).split("/", -1); + for (String rawToken : tokens) { + String token = rawToken.replace("~1", "/").replace("~0", "~"); + if (!(current instanceof Map)) { + return null; + } + @SuppressWarnings("unchecked") + Map object = (Map) current; + if (!object.containsKey(token)) { + return null; + } + current = object.get(token); + } + return current; + } + + private static ContentBlock contentBlock(McpContent content) { + if (content == null) { + throw new IllegalArgumentException("unsupported MCP content type null"); + } + if ("text".equals(content.getType())) { + return new ContentBlock("text", content.getText()); + } + if ("image".equals(content.getType())) { + if (!SUPPORTED_IMAGE_MIME_TYPES.contains(content.getMimeType())) { + throw new IllegalArgumentException( + "unsupported image MIME type: " + content.getMimeType()); + } + return base64Block("image", content.getMimeType(), content.getData()); + } + if ("resource".equals(content.getType())) { + return resourceBlock(content.getResource()); + } + throw new IllegalArgumentException("unsupported MCP content type: " + content.getType()); + } + + private static ContentBlock resourceBlock(McpResource resource) { + if (resource == null) { + throw new IllegalArgumentException("embedded MCP resource has no content"); + } + String mimeType = resource.getMimeType(); + if (SUPPORTED_IMAGE_MIME_TYPES.contains(mimeType)) { + if (resource.getBlob() == null) { + throw new IllegalArgumentException("image resource must contain blob data"); + } + return base64Block("image", mimeType, resource.getBlob()); + } + if ("application/pdf".equals(mimeType)) { + if (resource.getBlob() == null) { + throw new IllegalArgumentException("PDF resource must contain blob data"); + } + return base64Block("document", mimeType, resource.getBlob()); + } + if (mimeType == null || mimeType.isEmpty() || mimeType.startsWith("text/")) { + String text = resource.getText(); + if (text == null && resource.getBlob() != null) { + byte[] decoded = Base64.getDecoder().decode(resource.getBlob()); + text = new String(decoded, StandardCharsets.UTF_8); + } + ContentBlock block = new ContentBlock(); + block.setType("document"); + block.setSource(source("text", "text/plain", text == null ? "" : text)); + return block; + } + throw new IllegalArgumentException("unsupported resource MIME type: " + mimeType); + } + + private static ContentBlock base64Block(String type, String mimeType, String data) { + ContentBlock block = new ContentBlock(); + block.setType(type); + block.setSource(source("base64", mimeType, data == null ? "" : data)); + return block; + } + + private static Map source(String type, String mimeType, String data) { + Map source = new LinkedHashMap(); + source.put("type", type); + source.put("media_type", mimeType); + source.put("data", data); + return source; + } + + private static final class GenericMcpTool implements Tool { + private final String name; + private final McpClient client; + + private GenericMcpTool(String name, McpClient client) { + this.name = name; + this.client = client; + } + + @Override + public String name() { + return name; + } + + @Override + @SuppressWarnings("unchecked") + public ToolResult execute(Object input, ToolContext context) { + if (context != null && context.isCancelled()) { + return ToolResult.error("mcp tool execution cancelled"); + } + Map arguments; + if (input == null) { + arguments = Collections.emptyMap(); + } else if (input instanceof Map) { + arguments = new LinkedHashMap((Map) input); + } else { + return ToolResult.error("mcp tool input must be an object"); + } + try { + return toolResult(client.callTool(name, arguments, context)); + } catch (Exception exception) { + String message = exception.getMessage(); + return ToolResult.error("mcp tool " + name + ": " + + (message == null || message.isEmpty() ? exception.toString() : message)); + } + } + } + + private static final class SchemaConversion { + private final CustomToolInputSchema schema; + private final String constraints; + + private SchemaConversion(CustomToolInputSchema schema, String constraints) { + this.schema = schema; + this.constraints = constraints; + } + } + + private static final class ResolveResult { + private final Object value; + private final boolean unresolved; + + private ResolveResult(Object value, boolean unresolved) { + this.value = value; + this.unresolved = unresolved; + } + } +} diff --git a/src/test/java/com/volcengine/ark/runtime/interceptor/RetryInterceptorTest.java b/src/test/java/com/volcengine/ark/runtime/interceptor/RetryInterceptorTest.java new file mode 100644 index 0000000..866c55d --- /dev/null +++ b/src/test/java/com/volcengine/ark/runtime/interceptor/RetryInterceptorTest.java @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.interceptor; + +import static com.volcengine.ark.runtime.Const.RETRY_COUNT_HEADER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import okio.BufferedSink; +import okhttp3.OkHttpClient; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.junit.Test; + +public class RetryInterceptorTest { + @Test + public void retryAfterMillisecondsTakesPriorityAndRetryCountTracksAttempts() throws Exception { + AtomicInteger calls = new AtomicInteger(); + List retryCounts = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/retry", exchange -> { + retryCounts.add(exchange.getRequestHeaders().getFirst(RETRY_COUNT_HEADER)); + int call = calls.getAndIncrement(); + if (call == 0) { + exchange.getResponseHeaders().add("Retry-After-Ms", "125.5"); + exchange.getResponseHeaders().add("Retry-After", "9"); + exchange.sendResponseHeaders(429, -1); + } else { + exchange.sendResponseHeaders(200, -1); + } + exchange.close(); + }); + server.start(); + RecordingRetryInterceptor interceptor = new RecordingRetryInterceptor(2); + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor).build(); + try (Response response = client.newCall(new Request.Builder() + .url("http://127.0.0.1:" + server.getAddress().getPort() + "/retry") + .build()).execute()) { + assertEquals(200, response.code()); + } finally { + server.stop(0); + } + + assertEquals(Arrays.asList("0", "1"), retryCounts); + assertEquals(Arrays.asList(Duration.ofNanos(125_500_000L)), interceptor.delays); + } + + @Test + public void legacyRetryIntervalKeepsOriginalBehavior() { + RetryInterceptor interceptor = new RetryInterceptor(2); + double first = interceptor.retryInterval(2, 1); + double second = interceptor.retryInterval(2, 0); + assertTrue(first >= 0.75 && first <= 1.0); + assertTrue(second >= 1.5 && second <= 2.0); + } + + @Test + public void invalidServerRetryDelaysFallBackToInitialBackoff() throws Exception { + assertInvalidRetryAfterFallsBack("Retry-After", "0"); + assertInvalidRetryAfterFallsBack("Retry-After", "-1"); + assertInvalidRetryAfterFallsBack("Retry-After", "61"); + assertInvalidRetryAfterFallsBack("Retry-After", "Infinity"); + assertInvalidRetryAfterFallsBack("Retry-After-Ms", "NaN"); + } + + @Test + public void serverRetryOverridesAndCustomRetryHeaderAreHonored() throws Exception { + AtomicInteger forcedRetryCalls = new AtomicInteger(); + AtomicInteger suppressedRetryCalls = new AtomicInteger(); + List customRetryCounts = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/forced", exchange -> { + customRetryCounts.add(exchange.getRequestHeaders().getFirst(RETRY_COUNT_HEADER)); + if (forcedRetryCalls.getAndIncrement() == 0) { + exchange.getResponseHeaders().add("X-Should-Retry", "true"); + exchange.sendResponseHeaders(400, -1); + } else { + exchange.sendResponseHeaders(200, -1); + } + exchange.close(); + }); + server.createContext("/suppressed", exchange -> { + suppressedRetryCalls.incrementAndGet(); + exchange.getResponseHeaders().add("X-Should-Retry", "false"); + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + server.start(); + RecordingRetryInterceptor interceptor = new RecordingRetryInterceptor(2); + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor).build(); + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + try { + try (Response response = client.newCall(new Request.Builder() + .url(baseUrl + "/forced") + .header(RETRY_COUNT_HEADER, "custom") + .build()).execute()) { + assertEquals(200, response.code()); + } + try (Response response = client.newCall(new Request.Builder() + .url(baseUrl + "/suppressed") + .build()).execute()) { + assertEquals(500, response.code()); + } + } finally { + server.stop(0); + } + + assertEquals(Arrays.asList("custom", "custom"), customRetryCounts); + assertEquals(2, forcedRetryCalls.get()); + assertEquals(1, suppressedRetryCalls.get()); + } + + @Test + public void conflictIsRetriedByDefault() throws Exception { + AtomicInteger calls = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/conflict", exchange -> { + if (calls.getAndIncrement() == 0) { + exchange.sendResponseHeaders(409, -1); + } else { + exchange.sendResponseHeaders(200, -1); + } + exchange.close(); + }); + server.start(); + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(new RetryInterceptor(1)).build(); + try (Response response = client.newCall(new Request.Builder() + .url("http://127.0.0.1:" + server.getAddress().getPort() + "/conflict") + .build()).execute()) { + assertEquals(200, response.code()); + } finally { + server.stop(0); + } + assertEquals(2, calls.get()); + } + + @Test + public void runtimeExceptionsAreNotRetried() { + AtomicInteger calls = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("broken interceptor"); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new RetryInterceptor(2)) + .addInterceptor(chain -> { + calls.incrementAndGet(); + throw failure; + }) + .build(); + + try { + client.newCall(new Request.Builder().url("https://ark.example.com/test").build()).execute(); + } catch (IllegalStateException actual) { + assertTrue(actual == failure); + } catch (Exception actual) { + throw new AssertionError(actual); + } + assertEquals(1, calls.get()); + } + + @Test + public void oneShotRequestBodiesAreNotRetried() throws Exception { + AtomicInteger calls = new AtomicInteger(); + RequestBody oneShotBody = new RequestBody() { + @Override + public MediaType contentType() { + return MediaType.parse("application/json"); + } + + @Override + public void writeTo(BufferedSink sink) throws java.io.IOException { + sink.writeUtf8("{}"); + } + + @Override + public boolean isOneShot() { + return true; + } + }; + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new RetryInterceptor(2)) + .addInterceptor(chain -> { + calls.incrementAndGet(); + return new Response.Builder() + .request(chain.request()) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(500) + .message("error") + .body(okhttp3.ResponseBody.create(MediaType.parse("application/json"), "{}")) + .build(); + }) + .build(); + + try (Response response = client.newCall(new Request.Builder() + .url("https://ark.example.com/test") + .post(oneShotBody) + .build()).execute()) { + assertEquals(500, response.code()); + } + assertEquals(1, calls.get()); + } + + private void assertInvalidRetryAfterFallsBack(String header, String value) throws Exception { + AtomicInteger calls = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/retry", exchange -> { + if (calls.getAndIncrement() == 0) { + exchange.getResponseHeaders().add(header, value); + exchange.sendResponseHeaders(500, -1); + } else { + exchange.sendResponseHeaders(200, -1); + } + exchange.close(); + }); + server.start(); + RecordingRetryInterceptor interceptor = new RecordingRetryInterceptor(1); + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor).build(); + try (Response response = client.newCall(new Request.Builder() + .url("http://127.0.0.1:" + server.getAddress().getPort() + "/retry") + .build()).execute()) { + assertEquals(200, response.code()); + } finally { + server.stop(0); + } + assertEquals(1, interceptor.delays.size()); + Duration delay = interceptor.delays.get(0); + assertTrue(delay.compareTo(Duration.ofMillis(375)) >= 0); + assertTrue(delay.compareTo(Duration.ofMillis(500)) <= 0); + } + + private static final class RecordingRetryInterceptor extends RetryInterceptor { + private final List delays = new ArrayList<>(); + + private RecordingRetryInterceptor(int retryTimes) { + super(retryTimes); + } + + @Override + protected void sleep(Duration duration) { + delays.add(duration); + } + } +} diff --git a/src/test/java/com/volcengine/ark/runtime/selfhosted/ContentBlockTest.java b/src/test/java/com/volcengine/ark/runtime/selfhosted/ContentBlockTest.java new file mode 100644 index 0000000..141f552 --- /dev/null +++ b/src/test/java/com/volcengine/ark/runtime/selfhosted/ContentBlockTest.java @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.volcengine.ark.runtime.models.session.Base64ImageSource; +import com.volcengine.ark.runtime.models.session.ManagedAgentsEventParams; +import com.volcengine.ark.runtime.models.session.ManagedAgentsImageBlock; +import com.volcengine.ark.runtime.models.session.ManagedAgentsUserCustomToolResultEventParams; +import com.volcengine.ark.runtime.service.ArkService; +import org.junit.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ContentBlockTest { + @Test + public void toMapPreservesStructuredFields() { + Map source = new LinkedHashMap<>(); + source.put("type", "base64"); + source.put("media_type", "image/png"); + source.put("data", "aW1hZ2U="); + ContentBlock block = new ContentBlock(); + block.setType("image"); + block.setSource(source); + block.setTitle("result"); + block.setContext("generated by MCP"); + + Map value = block.toMap(); + + assertEquals("image", value.get("type")); + assertEquals(source, value.get("source")); + assertEquals("result", value.get("title")); + assertEquals("generated by MCP", value.get("context")); + } + + @Test + public void toMapPreservesRequiredEmptyText() { + ContentBlock block = new ContentBlock("text", ""); + + Map value = block.toMap(); + + assertTrue(value.containsKey("text")); + assertEquals("", value.get("text")); + } + + @Test + public void eventRoundTripAndGeneratedModelPreserveSource() { + Map source = new LinkedHashMap<>(); + source.put("type", "base64"); + source.put("media_type", "image/png"); + source.put("data", "aW1hZ2U="); + ContentBlock block = new ContentBlock(); + block.setType("image"); + block.setSource(source); + Event event = Event.newUserCustomToolResultEvent( + "call-1", Collections.singletonList(block), false, ""); + + Event restored = Event.fromMap(event.toMap()); + assertEquals(source, restored.getContent().get(0).getSource()); + + ManagedAgentsEventParams generated = ArkService.defaultObjectMapper().convertValue( + restored.toMap(), ManagedAgentsEventParams.class); + assertTrue(generated instanceof ManagedAgentsUserCustomToolResultEventParams); + ManagedAgentsUserCustomToolResultEventParams result = + (ManagedAgentsUserCustomToolResultEventParams) generated; + assertTrue(result.getContent().get(0) instanceof ManagedAgentsImageBlock); + ManagedAgentsImageBlock image = (ManagedAgentsImageBlock) result.getContent().get(0); + assertTrue(image.getSource() instanceof Base64ImageSource); + assertEquals("aW1hZ2U=", ((Base64ImageSource) image.getSource()).getData()); + } +} diff --git a/src/test/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorkerTest.java b/src/test/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorkerTest.java index c654d49..a7de386 100644 --- a/src/test/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorkerTest.java +++ b/src/test/java/com/volcengine/ark/runtime/selfhosted/EnvironmentWorkerTest.java @@ -4,6 +4,7 @@ package com.volcengine.ark.runtime.selfhosted; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import com.volcengine.ark.runtime.models.environment.HeartbeatWorkResponse; @@ -25,6 +26,15 @@ import org.junit.Test; public class EnvironmentWorkerTest { + @Test + public void defaultWorkerIdIsUniquePerWorker() { + String first = EnvironmentWorker.defaultWorkerId(); + String second = EnvironmentWorker.defaultWorkerId(); + + assertNotEquals(first, second); + assertEquals(12, first.substring(first.lastIndexOf('-') + 1).length()); + } + @Test public void workerUsesConfiguredWorkdir() throws Exception { Path workdir = Files.createTempDirectory("ark-java-worker-"); diff --git a/src/test/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClientTest.java b/src/test/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClientTest.java index 128fde4..6b4cda5 100644 --- a/src/test/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClientTest.java +++ b/src/test/java/com/volcengine/ark/runtime/selfhosted/SelfHostedClientTest.java @@ -66,6 +66,22 @@ public void preservesNestedSessionWorkData() { assertEquals("session-1", WorkItems.sessionId(item)); } + @Test + public void negativeBlockMillisOmitsLongPollQuery() { + AtomicReference requestedURL = new AtomicReference<>(); + OkHttpClient httpClient = new OkHttpClient.Builder() + .addInterceptor(chain -> response(chain.request(), requestedURL)) + .build(); + SelfHostedClient client = new SelfHostedClient.Builder() + .apiKey("test-api-key") + .httpClient(httpClient) + .build(); + + client.pollWork("env-1", "worker-1", -1, 0); + + assertNull(requestedURL.get().queryParameter("block_ms")); + } + @Test public void opensSkillHubFromMetadataAndVersionedDownload() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); @@ -168,10 +184,12 @@ public void gracefulStopSendsEmptyJSONBody() throws Exception { public void heartbeatUsesOneLeaseBoundedAttempt() { AtomicInteger calls = new AtomicInteger(); AtomicLong timeoutSeconds = new AtomicLong(); + AtomicReference retryCount = new AtomicReference<>(); OkHttpClient httpClient = new OkHttpClient.Builder() .addInterceptor(new RetryInterceptor(3)) .addInterceptor(chain -> { calls.incrementAndGet(); + retryCount.set(chain.request().header("X-Stainless-Retry-Count")); timeoutSeconds.set( TimeUnit.NANOSECONDS.toSeconds(chain.call().timeout().timeoutNanos())); return new Response.Builder() @@ -196,6 +214,7 @@ public void heartbeatUsesOneLeaseBoundedAttempt() { assertEquals(1, calls.get()); assertEquals(15L, timeoutSeconds.get()); + assertEquals("0", retryCount.get()); } @Test diff --git a/src/test/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolsTest.java b/src/test/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolsTest.java new file mode 100644 index 0000000..69f0618 --- /dev/null +++ b/src/test/java/com/volcengine/ark/runtime/selfhosted/mcp/McpToolsTest.java @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.selfhosted.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.volcengine.ark.runtime.models.agent.ToolItem; +import com.volcengine.ark.runtime.selfhosted.ContentBlock; +import com.volcengine.ark.runtime.selfhosted.Tool; +import com.volcengine.ark.runtime.selfhosted.ToolContext; +import com.volcengine.ark.runtime.selfhosted.ToolResult; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class McpToolsTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + public void customToolItemAdaptsSchemaToCurrentAgentContract() throws Exception { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + schema.put("properties", Collections.singletonMap( + "order_id", Collections.singletonMap("$ref", "#/$defs/order_id"))); + schema.put("required", Collections.singletonList("order_id")); + schema.put("additionalProperties", false); + schema.put("$schema", "https://json-schema.org/draft/2020-12/schema"); + Map orderID = new LinkedHashMap(); + orderID.put("type", "string"); + orderID.put("minLength", 1); + schema.put("$defs", Collections.singletonMap( + "order_id", orderID)); + + ToolItem item = McpTools.customToolItem(new McpToolDefinition( + "lookup_order", "Lookup an order", schema)); + String raw = OBJECT_MAPPER.writeValueAsString(item.getInputSchema()); + Assert.assertFalse(raw, raw.contains("\"additionalProperties\"")); + Assert.assertFalse(raw, raw.contains("\"$defs\"")); + Assert.assertFalse(raw, raw.contains("\"$ref\"")); + Assert.assertTrue(raw, raw.contains("\"minLength\":1")); + Assert.assertTrue(item.getDescription(), item.getDescription().startsWith("Lookup an order")); + Assert.assertTrue(item.getDescription(), item.getDescription().contains("\"additionalProperties\":false")); + Assert.assertFalse(item.getDescription(), item.getDescription().contains("\"$defs\"")); + Assert.assertFalse(item.getDescription(), item.getDescription().contains("\"$schema\"")); + } + + @Test + public void customToolItemRetainsReferencedDefinitions() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + schema.put("allOf", Collections.singletonList( + Collections.singletonMap("$ref", "#/$defs/constraint"))); + schema.put("$defs", Collections.singletonMap( + "constraint", Collections.singletonMap("additionalProperties", false))); + + ToolItem item = McpTools.customToolItem(new McpToolDefinition("lookup", "", schema)); + + Assert.assertTrue(item.getDescription(), item.getDescription().contains("\"$defs\"")); + Assert.assertTrue( + item.getDescription(), + item.getDescription().contains("\"$ref\":\"#/$defs/constraint\"")); + } + + @Test + public void customToolItemDescribesUnresolvedReferences() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + schema.put("properties", Collections.singletonMap( + "order", Collections.singletonMap("$ref", "https://example.com/order.json"))); + + ToolItem item = McpTools.customToolItem(new McpToolDefinition("lookup", "", schema)); + + Assert.assertTrue( + item.getDescription(), + item.getDescription().contains("\"$ref\":\"https://example.com/order.json\"")); + Assert.assertEquals( + Collections.emptyMap(), + item.getInputSchema().getProperties().get("order")); + } + + @Test(expected = IllegalArgumentException.class) + public void customToolItemRejectsOversizedCompatibilityDescription() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + McpTools.customToolItem(new McpToolDefinition( + "large", String.join("", Collections.nCopies(10_000, "a")), schema)); + } + + @Test + public void mcpToolCallsGenericClient() { + final Map observed = new LinkedHashMap(); + McpClient client = new McpClient() { + @Override + public McpCallToolResult callTool( + String name, + Map arguments, + ToolContext context) { + observed.put("name", name); + observed.put("arguments", arguments); + return new McpCallToolResult( + Collections.singletonList(new McpContent( + "text", "echo: hello", null, null, null)), + null, + false); + } + }; + Tool tool = McpTools.mcpTool( + new McpToolDefinition("echo", "", null), + client); + ToolResult result = tool.execute( + Collections.singletonMap("text", "hello"), + new ToolContext(".")); + Assert.assertFalse(result.isError()); + Assert.assertEquals("echo: hello", result.getContent().get(0).getText()); + Assert.assertEquals("echo", observed.get("name")); + } + + @Test + public void convertsRichContentAndStructuredFallback() { + List content = Arrays.asList( + new McpContent("image", null, "image/png", "aW1hZ2U=", null), + new McpContent("resource", null, null, null, new McpResource( + "file:///result.txt", "text/plain", "resource text", null))); + ToolResult rich = McpTools.toolResult(new McpCallToolResult(content, null, false)); + Assert.assertFalse(rich.isError()); + @SuppressWarnings("unchecked") + Map image = (Map) rich.getContent().get(0).getSource(); + Assert.assertEquals("aW1hZ2U=", image.get("data")); + + ToolResult structured = McpTools.toolResult(new McpCallToolResult( + Collections.emptyList(), + Collections.singletonMap("status", "ok"), + false)); + ContentBlock block = structured.getContent().get(0); + Assert.assertEquals("{\"status\":\"ok\"}", block.getText()); + } + + @Test + public void normalizesTextResourceMimeType() { + McpContent content = new McpContent("resource", null, null, null, new McpResource( + "file:///result.html", "text/html", "

hello

", null)); + ToolResult result = McpTools.toolResult(new McpCallToolResult( + Collections.singletonList(content), null, false)); + + @SuppressWarnings("unchecked") + Map source = (Map) result.getContent().get(0).getSource(); + Assert.assertEquals("text/plain", source.get("media_type")); + } + + @Test + public void convertsEmptyErrorResultWithMessage() { + ToolResult result = McpTools.toolResult(new McpCallToolResult( + Collections.emptyList(), null, true)); + + Assert.assertTrue(result.isError()); + Assert.assertEquals(1, result.getContent().size()); + Assert.assertEquals("tool returned an error", result.getContent().get(0).getText()); + } + + @Test + public void conversionErrorDoesNotExposeResourceURI() { + String secretURI = "https://example.com/file?signature=secret"; + McpResource resource = new McpResource(secretURI, "image/png", null, null); + + ToolResult result = McpTools.toolResult(new McpCallToolResult( + Collections.singletonList(new McpContent( + "resource", null, null, null, resource)), + null, + false)); + + Assert.assertTrue(result.isError()); + Assert.assertFalse(result.getContent().get(0).getText().contains(secretURI)); + Assert.assertFalse(result.getContent().get(0).getText().contains("secret")); + } + + @Test + public void resolvesJSONPointerWithTrailingEmptyToken() throws Exception { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + schema.put("properties", Collections.singletonMap( + "value", Collections.singletonMap("$ref", "#/$defs/"))); + schema.put("$defs", Collections.singletonMap( + "", Collections.singletonMap("type", "string"))); + + ToolItem item = McpTools.customToolItem(new McpToolDefinition("lookup", "", schema)); + String raw = OBJECT_MAPPER.writeValueAsString(item.getInputSchema()); + + Assert.assertTrue(raw, raw.contains("\"type\":\"string\"")); + Assert.assertFalse(raw, raw.contains("\"$ref\"")); + } +}