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