Repository navigation
fix(harness): bound MCP bridge shutdown and retain live servers #1099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
burtenshaw
merged 37 commits into
openenv:main
from
splusq:rfc-005/pr3-harness-environment-runtime
Sep 30, 2026
Merged
Changes from 29 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
3c99e0f
refactor(harness): split openenv.core.harness into a package
splusq 01bb0ce
feat(harness): RFC 005 foundation types for agentic harnesses
splusq f3d90c2
feat(harness): HarnessEnvironment, subprocess helper, and MCP tool br…
splusq f39c662
Merge branch 'main' into rfc-005/pr1-harness-package-split
splusq b6e6984
Merge branch 'main' into rfc-005/pr2-harness-foundation-types
splusq 025191d
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq 671f5c0
Merge branch 'main' into rfc-005/pr1-harness-package-split
splusq 933e113
Merge branch 'main' into rfc-005/pr2-harness-foundation-types
splusq 668ab2e
fix(harness): address Bugbot review on the environment runtime
splusq 7cda7a3
fix: refresh harness package split
burtenshaw c9e4316
fix: refresh harness foundation types
burtenshaw cf760ba
fix: refresh harness runtime
burtenshaw 1a6caa5
fix: wake harness readers on eof
burtenshaw 01b09e5
fix: refresh latest core state changes
burtenshaw 5c11106
fix: refresh foundation base
burtenshaw 8736543
fix: refresh runtime base
burtenshaw a81f0a2
chore: refresh latest main
burtenshaw 4de0d41
chore: refresh foundation base
burtenshaw 7401fe0
chore: refresh runtime base
burtenshaw 2e622ad
Merge remote-tracking branch 'origin/main' into rfc-005/pr3-harness-e…
splusq 4ad1fb2
fix(harness): apply transforms and clean up exited processes
splusq e317462
fix(harness): clean up cancelled reset and startup
splusq 26a5043
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq a2bf6f6
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq 7225191
Merge main into RFC 005 harness runtime; resolve package split conflicts
splusq 0cc7d91
fix(harness): clean up cancelled conversational turns
splusq 87a7772
Merge main after RFC 005 foundation types; preserve runtime exports
splusq d945683
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq 079a6d5
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw 412538b
Merge main and preserve runtime fixes from PR 1100
splusq 86f344c
fix(harness): bound MCP bridge shutdown and retain live servers
splusq 10e252a
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq 329e4ba
fix: retain bridges after failed shutdown
burtenshaw 0b6627d
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw ad5572e
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw 24e345a
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw 6e5c36c
fix: avoid duplicate bridge shutdown wait
burtenshaw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| # SPDX-License-Identifier: BSD-3-Clause | ||
|
|
||
| """Loopback MCP bridge exposing environment tools to a harness (RFC 005).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import socket | ||
| import threading | ||
| import time | ||
| from typing import Any, Optional | ||
|
|
||
| from .adapter import HarnessError | ||
|
|
||
|
|
||
| async def _source_tools(mcp_server: Any) -> dict[str, Any]: | ||
| """Return {name: FastMCP tool}, compatible with FastMCP 2.x and 3.x. | ||
|
|
||
| Async twin of [`~openenv.core.env_server.mcp_environment.get_server_tools`], | ||
| which wraps the same calls in `run_async_safely`; awaiting directly avoids | ||
| spawning a thread when we are already on a loop. | ||
| """ | ||
| if hasattr(mcp_server, "get_tools"): | ||
| result = await mcp_server.get_tools() | ||
| if isinstance(result, dict): | ||
| return result | ||
| if hasattr(mcp_server, "list_tools"): | ||
| return {tool.name: tool for tool in await mcp_server.list_tools()} | ||
| return {} | ||
|
|
||
|
|
||
| async def build_bridge_server(mcp_server: Any, renames: dict[str, str]) -> Any: | ||
| """ | ||
| Return a FastMCP server exposing the env's tools under their injected names. | ||
|
|
||
| Tool-name conflict resolution can rename an environment tool before it is | ||
| injected into a harness (`read_file` -> `env_read_file`). The harness then | ||
| calls the new name, so the bridge has to answer to it: serving the source | ||
| server unchanged would advertise a name that does not resolve. | ||
|
|
||
| Args: | ||
| mcp_server (`FastMCP`): | ||
| The environment's own tool server. | ||
| renames (`dict[str, str]`): | ||
| Mapping of injected name to source name. Empty means no renames. | ||
|
|
||
| Returns: | ||
| `FastMCP`: `mcp_server` itself when there is nothing to rename, | ||
| otherwise a view of it whose tools carry the injected names. | ||
| """ | ||
| if not renames: | ||
| return mcp_server | ||
|
|
||
| from fastmcp import FastMCP | ||
| from fastmcp.tools.tool import Tool as FastMCPTool | ||
|
|
||
| source_tools = await _source_tools(mcp_server) | ||
| missing = sorted(set(renames.values()) - set(source_tools)) | ||
| if missing: | ||
| raise HarnessError( | ||
| "Cannot rename tools that the MCP server does not expose: " | ||
| + ", ".join(missing) | ||
| ) | ||
|
|
||
| served_name_of = {source: served for served, source in renames.items()} | ||
| view = FastMCP(f"{getattr(mcp_server, 'name', 'openenv')}-harness-view") | ||
| for name, tool in source_tools.items(): | ||
| served_name = served_name_of.get(name, name) | ||
| view.add_tool( | ||
| tool | ||
| if served_name == name | ||
| else FastMCPTool.from_tool(tool, name=served_name) | ||
| ) | ||
| return view | ||
|
|
||
|
|
||
| class HarnessMCPBridge: | ||
| """ | ||
| Serve an in-process FastMCP server over loopback HTTP for harness use. | ||
|
|
||
| The bridge runs the environment's FastMCP tool server as a | ||
| streamable-HTTP ASGI app on `127.0.0.1` with an ephemeral port, inside a | ||
| daemon thread with its own event loop. It carries only the tool surface: | ||
| the harness subprocess can reach the environment's domain tools but never | ||
| OpenEnv's orchestration API (`reset`/`step`/`state`), which lives on a | ||
| different server entirely. This makes the RFC 005 security boundary | ||
| structural rather than filter-based. | ||
|
|
||
| Args: | ||
| mcp_server (`FastMCP`): | ||
| The environment's FastMCP server to expose. | ||
| host (`str`, *optional*, defaults to `"127.0.0.1"`): | ||
| Interface to bind. Keep this loopback-only. | ||
|
|
||
| Examples: | ||
|
|
||
| ```python | ||
| bridge = HarnessMCPBridge(env.mcp_server) | ||
| url = bridge.start() | ||
| # pass url to the harness adapter's inject_tools() | ||
| bridge.stop() | ||
| ``` | ||
| """ | ||
|
|
||
| def __init__(self, mcp_server: Any, host: str = "127.0.0.1"): | ||
| self._mcp_server = mcp_server | ||
| self._host = host | ||
| self._url: Optional[str] = None | ||
| self._thread: Optional[threading.Thread] = None | ||
| self._uvicorn_server: Optional[Any] = None | ||
| self._startup_error: Optional[BaseException] = None | ||
|
|
||
| @property | ||
| def url(self) -> Optional[str]: | ||
| """The bridge's MCP endpoint URL, or `None` when not running.""" | ||
| return self._url | ||
|
|
||
| def start(self, timeout_s: float = 10.0) -> str: | ||
| """ | ||
| Start serving and return the MCP endpoint URL. | ||
|
|
||
| Idempotent: returns the existing URL if already running. | ||
|
|
||
| Args: | ||
| timeout_s (`float`, *optional*, defaults to `10.0`): | ||
| Maximum time to wait for the server to come up. | ||
|
|
||
| Returns: | ||
| `str` URL of the MCP endpoint, e.g. `"http://127.0.0.1:54321/mcp"`. | ||
|
|
||
| Raises: | ||
| [`~openenv.core.harness.adapter.HarnessError`]: | ||
| If the server fails to start within the timeout. | ||
| """ | ||
| if self._thread is not None and self._thread.is_alive(): | ||
| assert self._url is not None | ||
| return self._url | ||
|
|
||
| import uvicorn | ||
|
|
||
| app = self._mcp_server.http_app() | ||
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||
| sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
| sock.bind((self._host, 0)) | ||
| port = sock.getsockname()[1] | ||
|
|
||
| config = uvicorn.Config(app, log_level="warning", lifespan="on") | ||
| server = uvicorn.Server(config) | ||
| self._uvicorn_server = server | ||
| self._startup_error = None | ||
|
|
||
| def _serve() -> None: | ||
| try: | ||
| server.run(sockets=[sock]) | ||
| except BaseException as exc: # surfaced to start() below | ||
| self._startup_error = exc | ||
| finally: | ||
| try: | ||
| sock.close() | ||
| except OSError: | ||
| pass | ||
|
|
||
| thread = threading.Thread(target=_serve, daemon=True) | ||
| thread.start() | ||
| self._thread = thread | ||
|
|
||
| deadline = time.monotonic() + timeout_s | ||
| while not server.started: | ||
| if self._startup_error is not None or not thread.is_alive(): | ||
| self._teardown() | ||
| raise HarnessError(f"MCP bridge failed to start: {self._startup_error}") | ||
| if time.monotonic() > deadline: | ||
| self.stop() | ||
| raise HarnessError(f"MCP bridge did not start within {timeout_s}s") | ||
| time.sleep(0.01) | ||
|
|
||
| self._url = f"http://{self._host}:{port}/mcp" | ||
| return self._url | ||
|
|
||
| def stop(self, timeout_s: float = 5.0) -> None: | ||
| """ | ||
| Stop the bridge server. | ||
|
|
||
| Idempotent: safe to call when the bridge was never started. | ||
|
|
||
| Args: | ||
| timeout_s (`float`, *optional*, defaults to `5.0`): | ||
| Maximum time to wait for the server thread to exit. | ||
| """ | ||
| server = self._uvicorn_server | ||
| thread = self._thread | ||
| if server is not None: | ||
| server.should_exit = True | ||
| if thread is not None and thread.is_alive(): | ||
| thread.join(timeout=timeout_s) | ||
| self._teardown() | ||
|
|
||
| def _teardown(self) -> None: | ||
| self._url = None | ||
| self._thread = None | ||
| self._uvicorn_server = None | ||
|
|
||
|
|
||
| __all__ = ["build_bridge_server", "HarnessMCPBridge"] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.