Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,29 @@ jobs:

- name: Check formatting
run: uv run ruff format --check src/

- name: Test self-hosted core
run: uv run pytest -q tests/

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 Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install package with MCP adapter
run: python -m pip install --disable-pip-version-check --quiet '.[mcp]' pytest trio

- name: Test MCP adapter and example
run: pytest -q tests/test_mcp_adapter.py tests/test_self_hosted_mcp_example.py

- name: Check MCP example source
run: python -m py_compile examples/self_hosted_mcp_worker/main.py examples/self_hosted_mcp_worker/server.py
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,72 @@ See the [examples/](./examples) directory for runnable scripts:

MCP examples are provided for both clouds and explicitly send `ark-beta-mcp: true`. Other built-in-tool examples are CN-only and show their required beta headers.

## Self-hosted MCP tools

The self-hosted worker core exposes a protocol-independent `MCPClient`
interface without requiring the official MCP package. Install the optional
adapter on Python 3.10 or newer when using an official MCP `ClientSession`:

```bash
pip install 'arkruntime[mcp]'
```

The official adapter runs through an AnyIO `BlockingPortal`, so it works with
both asyncio and Trio backends. Create the portal and MCP `ClientSession` in
the same AnyIO lifecycle, then use `custom_tool_items()` for the Agent
declaration and `mcp_tools()` for the worker registry. Keep both alive for the
entire worker lifetime. See the runnable
[`self_hosted_mcp_worker`](./examples/self_hosted_mcp_worker) example.

The adapter wraps an already connected MCP `ClientSession`, so applications may
use stdio or another transport supported by their MCP client. One client
session 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.

Applications that cannot install the optional package may implement
`arkruntime.selfhosted.MCPClient` directly. The core SDK continues to support
Python 3.8, while the official MCP adapter requires Python 3.10 or newer.

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.

Fetch every `tools/list` page, then use the exact same selected definitions for
the Agent and worker. Managed Agents currently accepts at most eight custom
tools per Agent. Tool discovery happens at worker startup, so update the Agent
while it is idle and restart the worker whenever the MCP server changes its
tool list.

Custom tools do not use Managed Agents permission policies. The worker executes
each matching call, so implement approval, authorization, and operation
allowlists in the MCP server or a wrapper tool. Only wrap trusted servers,
avoid names that collide with built-in Agent tools, add prefixes when multiple
servers expose the same name, and configure an MCP client timeout. Client-side
MCP servers run with the worker's OS, filesystem, and network permissions, not
in a Managed Agents sandbox. Run them with least privilege and a minimal
environment, and do not pass `ARK_API_KEY` to an MCP subprocess. Tool names,
descriptions, inputs, and results enter the model context and must be treated
as untrusted content.

### 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.

## Requirements

- Python >= 3.8
Expand Down
7 changes: 4 additions & 3 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ 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/arkruntime/selfhosted` 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/arkruntime/selfhosted` and
`src/arkruntime/mcp.py` are structurally adapted from Anthropic's SDK
implementations:

- https://github.com/anthropics/anthropic-sdk-python
- https://github.com/anthropics/anthropic-sdk-go
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ All service-calling examples are grouped by cloud:

[`self_hosted_worker.py`](./self_hosted_worker.py) demonstrates the Managed-Agents self-hosted worker poll/handle loop and uses the client's production default `https://ark.cn-beijing.volces.com/api/v3`.

[`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.

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 examples explicitly send `ark-beta-mcp: true`. Other built-in tools are CN-only: Web Search sends `ark-beta-web-search: true`, and Doubao App sends `ark-beta-doubao-app: true`.
119 changes: 119 additions & 0 deletions examples/self_hosted_mcp_worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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.

Prepare the repository environment with the optional MCP dependency:

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -e '.[mcp]'
```

## 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

python examples/self_hosted_mcp_worker/main.py -- \
python examples/self_hosted_mcp_worker/server.py
```

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": {"title": "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.
134 changes: 134 additions & 0 deletions examples/self_hosted_mcp_worker/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright (c) 2026 ByteDance Ltd. and/or its affiliates.
# SPDX-License-Identifier: Apache-2.0
"""Self-hosted worker with client-side MCP tools.

Required::

export ARK_API_KEY=...
export MA_ENVIRONMENT_ID=env_xxx

Run the bundled MCP server from the repository root::

python examples/self_hosted_mcp_worker/main.py -- \
python examples/self_hosted_mcp_worker/server.py
"""

from __future__ import annotations

import json
import logging
import os
import signal
import sys
from typing import Dict, List, Optional

import anyio
from anyio.from_thread import BlockingPortal
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client

from arkruntime import Ark
from arkruntime.mcp import custom_tool_items, mcp_tools
from arkruntime.selfhosted import ClientAPI, EnvironmentWorker, EnvironmentWorkerOptions


def required_env(name: str) -> str:
"""Return a required environment variable."""

value = os.environ.get(name, "")
if not value:
raise RuntimeError(f"{name} is required")
return value


def mcp_command_args(args: List[str]) -> List[str]:
"""Remove the optional argument separator from an MCP command."""

return args[1:] if args and args[0] == "--" else args


def environment_without(name: str) -> Dict[str, str]:
"""Copy the process environment without a worker credential."""

return {key: value for key, value in os.environ.items() if key != name}


async def list_all_tools(session: ClientSession) -> List[types.Tool]:
"""Return every page from the MCP tools/list endpoint."""

tools: List[types.Tool] = []
cursor: Optional[str] = None
while True:
params = types.PaginatedRequestParams(cursor=cursor) if cursor else None
page = await session.list_tools(params=params)
tools.extend(page.tools)
cursor = page.next_cursor
if not cursor:
return tools


async def run() -> None:
"""Connect to MCP and run the self-hosted worker."""

api_key = required_env("ARK_API_KEY")
environment_id = required_env("MA_ENVIRONMENT_ID")
command = mcp_command_args(sys.argv[1:])
if not command:
raise RuntimeError(
"MCP server command is required; example: "
"python examples/self_hosted_mcp_worker/main.py -- "
"python examples/self_hosted_mcp_worker/server.py"
)

server = StdioServerParameters(
command=command[0],
args=command[1:],
env=environment_without("ARK_API_KEY"),
)
async with stdio_client(server) as (read, write):
async with ClientSession(read, write) as mcp_session:
await mcp_session.initialize()
tools = await list_all_tools(mcp_session)
for declaration in custom_tool_items(tools):
print(
"Agent custom tool: "
+ json.dumps(declaration.to_dict(), ensure_ascii=False, separators=(",", ":")),
flush=True,
)

async with BlockingPortal() as portal:
options = {"api_key": api_key}
base_url = os.environ.get("ARK_BASE_URL", "")
if base_url:
options["base_url"] = base_url
client = Ark(**options)
worker = EnvironmentWorker(
ClientAPI(client),
EnvironmentWorkerOptions(
environment_id=environment_id,
workdir=".",
custom_tools=mcp_tools(tools, mcp_session, portal=portal),
),
)
previous_handlers = {
sig: signal.signal(sig, lambda _signum, _frame: worker.close())
for sig in (signal.SIGINT, signal.SIGTERM)
}
try:
await anyio.to_thread.run_sync(worker.run)
finally:
worker.close()
client.close()
for sig, handler in previous_handlers.items():
signal.signal(sig, handler)


def main() -> None:
"""Run the example with the default AnyIO backend."""

logging.basicConfig(level=logging.INFO)
anyio.run(run)


if __name__ == "__main__":
main()
Loading
Loading