mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
feat: support SSE and streamable HTTP for OpenSpace MCP
* feat: add HTTP MCP startup modes * feat: add HTTP MCP startup modes * feat: add HTTP MCP startup modes
This commit is contained in:
parent
b0021b46bb
commit
114f06bd41
6 changed files with 228 additions and 69 deletions
12
README.md
12
README.md
|
|
@ -195,6 +195,18 @@ Works with any agent that supports skills (`SKILL.md`) — [Claude Code](https:/
|
|||
> [!TIP]
|
||||
> Credentials (API key, model) are **auto-detected** from your agent's config; you usually don't need to set them manually.
|
||||
|
||||
> [!NOTE]
|
||||
> OpenSpace supports 3 launch modes:
|
||||
> - **stdio**: keep `command: "openspace-mcp"` in the host config.
|
||||
> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`.
|
||||
> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`.
|
||||
>
|
||||
> Common remote endpoints:
|
||||
> - SSE endpoint: `http://127.0.0.1:8080/sse`
|
||||
> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp`
|
||||
>
|
||||
> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply.
|
||||
|
||||
**② Copy skills** into your agent's skills directory:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
12
README_CN.md
12
README_CN.md
|
|
@ -195,6 +195,18 @@ openspace-mcp --help # 验证安装
|
|||
> [!TIP]
|
||||
> 凭证(API 密钥、模型)会从你的 Agent 配置中**自动检测**,通常无需手动设置。
|
||||
|
||||
> [!NOTE]
|
||||
> OpenSpace 支持 3 种启动方式:
|
||||
> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`。
|
||||
> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`。
|
||||
> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`。
|
||||
>
|
||||
> 通用远端 endpoint:
|
||||
> - SSE: `http://127.0.0.1:8080/sse`
|
||||
> - streamable HTTP: `http://127.0.0.1:8081/mcp`
|
||||
>
|
||||
> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**。
|
||||
|
||||
**② 将 Skill 复制**到你的 Agent Skill 目录:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseCo
|
|||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
def _build_sse_candidate_urls(base_url: str) -> list[str]:
|
||||
"""Try the common FastMCP `/sse` endpoint before the raw base URL."""
|
||||
normalized = base_url.rstrip("/")
|
||||
if normalized.endswith("/sse"):
|
||||
return [normalized]
|
||||
return [f"{normalized}/sse", normalized]
|
||||
|
||||
|
||||
class HttpConnector(MCPBaseConnector):
|
||||
"""Connector for MCP implementations using HTTP transport.
|
||||
|
||||
|
|
@ -210,69 +218,72 @@ class HttpConnector(MCPBaseConnector):
|
|||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
# Try SSE fallback
|
||||
try:
|
||||
logger.debug(f"Attempting SSE fallback connection to: {self.base_url}")
|
||||
connection_manager = SseConnectionManager(
|
||||
self.base_url, self.headers, self.timeout, self.sse_read_timeout
|
||||
)
|
||||
|
||||
# Test the connection by starting it with built-in timeout
|
||||
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
|
||||
|
||||
# Create and verify ClientSession
|
||||
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
|
||||
|
||||
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
|
||||
# to avoid cancel scope conflicts with background tasks
|
||||
# Try SSE fallback. FastMCP commonly exposes legacy SSE on `/sse`,
|
||||
# but some callers may already pass the full endpoint.
|
||||
for sse_url in _build_sse_candidate_urls(self.base_url):
|
||||
connection_manager = None
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
|
||||
logger.debug(f"Attempting SSE fallback connection to: {sse_url}")
|
||||
connection_manager = SseConnectionManager(
|
||||
sse_url, self.headers, self.timeout, self.sse_read_timeout
|
||||
)
|
||||
|
||||
try:
|
||||
# Test the connection by starting it with built-in timeout
|
||||
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
|
||||
|
||||
# Create and verify ClientSession
|
||||
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
|
||||
|
||||
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
|
||||
# to avoid cancel scope conflicts with background tasks
|
||||
try:
|
||||
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
|
||||
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
|
||||
|
||||
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
|
||||
# Store it directly as the client_session for later use
|
||||
self.transport_type = "SSE"
|
||||
self._connection_manager = connection_manager
|
||||
self._connection = connection_manager.get_streams()
|
||||
self.client_session = test_client # Reuse the working session
|
||||
logger.debug("SSE transport selected")
|
||||
return
|
||||
except TimeoutError:
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise
|
||||
except Exception as init_error:
|
||||
# Clean up the test client only on error
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise init_error
|
||||
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
|
||||
|
||||
except Exception as e:
|
||||
sse_error = e
|
||||
logger.debug(f"SSE failed: {e}")
|
||||
|
||||
# Clean up the failed connection manager
|
||||
if connection_manager:
|
||||
try:
|
||||
await asyncio.wait_for(connection_manager.stop(), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
|
||||
|
||||
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
|
||||
# Store it directly as the client_session for later use
|
||||
self.transport_type = "SSE"
|
||||
self._connection_manager = connection_manager
|
||||
self._connection = connection_manager.get_streams()
|
||||
self.client_session = test_client # Reuse the working session
|
||||
logger.debug("SSE transport selected")
|
||||
return
|
||||
except TimeoutError:
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise
|
||||
except Exception as init_error:
|
||||
# Clean up the test client only on error
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise init_error
|
||||
|
||||
except Exception as e:
|
||||
sse_error = e
|
||||
logger.debug(f"SSE failed for {sse_url}: {e}")
|
||||
|
||||
# Clean up the failed connection manager
|
||||
if connection_manager:
|
||||
try:
|
||||
await asyncio.wait_for(connection_manager.stop(), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
# Both MCP transports failed, try simple JSON-RPC HTTP as last resort
|
||||
# This is useful for custom MCP servers that don't implement proper MCP transports
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
This guide covers **agent-specific setup** for integrating OpenSpace. For installation and general concepts, see the [main README](../../README.md#-quick-start).
|
||||
|
||||
**Quick recommendation:**
|
||||
- Use **stdio** if you want the simplest setup.
|
||||
- For **nanobot**, prefer **SSE** if you want OpenSpace to run as a standalone server.
|
||||
- For **openclaw**, prefer **streamable-http** for remote HTTP transport.
|
||||
|
||||
**Common remote endpoints:**
|
||||
- Start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080` and use `http://127.0.0.1:8080/sse`
|
||||
- Start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081` and use `http://127.0.0.1:8081/mcp`
|
||||
|
||||
The endpoint is common; the **host config syntax is not**. nanobot uses `tools.mcpServers`, while openclaw uses `openclaw mcp set`.
|
||||
|
||||
**Pick your agent:**
|
||||
|
||||
| Agent | Setup Guide |
|
||||
|
|
@ -21,7 +32,7 @@ cp -r host_skills/skill-discovery/ /path/to/nanobot/nanobot/skills/
|
|||
cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
|
||||
```
|
||||
|
||||
### 2. Add MCP server to `~/.nanobot/config.json`
|
||||
### 2. Option A: stdio (simplest)
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -44,6 +55,40 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
|
|||
> [!TIP]
|
||||
> LLM credentials are auto-detected from nanobot's `providers.*` config — no need to set `OPENSPACE_LLM_API_KEY`.
|
||||
|
||||
### 3. Option B: remote HTTP transport
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"type": "sse",
|
||||
"url": "http://127.0.0.1:8080/sse",
|
||||
"toolTimeout": 1200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://127.0.0.1:8081/mcp",
|
||||
"toolTimeout": 1200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`toolTimeout` still matters here. Changing transport to `sse` or `streamableHttp` does **not** remove nanobot's per-call timeout for slow MCP tools.
|
||||
|
||||
---
|
||||
|
||||
## Setup for openclaw
|
||||
|
|
@ -55,7 +100,7 @@ cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/
|
|||
cp -r host_skills/delegate-task/ /path/to/openclaw/skills/
|
||||
```
|
||||
|
||||
### 2. Register MCP server with env vars
|
||||
### 2. Option A: stdio via mcporter
|
||||
|
||||
openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command:
|
||||
|
||||
|
|
@ -66,6 +111,20 @@ mcporter config add openspace --command "openspace-mcp" \
|
|||
--env OPENSPACE_API_KEY=sk-xxx
|
||||
```
|
||||
|
||||
### 3. Option B: remote HTTP transport
|
||||
|
||||
```bash
|
||||
openclaw mcp set openspace '{"url":"http://127.0.0.1:8081/mcp","transport":"streamable-http","connectionTimeoutMs":10000}'
|
||||
```
|
||||
|
||||
If you specifically want legacy SSE instead, OpenClaw also supports:
|
||||
|
||||
```bash
|
||||
openclaw mcp set openspace '{"url":"http://127.0.0.1:8080","connectionTimeoutMs":10000}'
|
||||
```
|
||||
|
||||
`connectionTimeoutMs` controls connection establishment for the remote server. It does **not** guarantee unlimited runtime for a long-running MCP tool call.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Agent-Specific)
|
||||
|
|
@ -93,7 +152,7 @@ All tools default to `"all"` (local + cloud) and **automatically fall back** to
|
|||
```
|
||||
Your Agent (nanobot / openclaw / ...)
|
||||
│
|
||||
│ MCP protocol (stdio)
|
||||
│ MCP protocol (stdio | HTTP/SSE | streamable-http)
|
||||
▼
|
||||
openspace-mcp ← 4 tools exposed
|
||||
├── execute_task ← multi-step grounding agent loop
|
||||
|
|
@ -112,4 +171,4 @@ The two host skills teach the agent **when and how** to call these tools:
|
|||
Skills auto-evolve inside `execute_task` (**FIX** / **DERIVED** / **CAPTURED**). After every call, your agent reports results to the user via its messaging tool.
|
||||
|
||||
> [!NOTE]
|
||||
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.
|
||||
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: Delegate tasks to OpenSpace — a full-stack autonomous worker for
|
|||
|
||||
# Delegate Tasks to OpenSpace
|
||||
|
||||
OpenSpace is connected as an MCP server. You have 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
|
||||
OpenSpace is connected as an MCP server. Whether the host uses `stdio`, `sse`, or `streamable-http`, you have the same 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -127,5 +127,6 @@ upload_skill(
|
|||
## Notes
|
||||
|
||||
- `execute_task` may take minutes — this is expected for multi-step tasks.
|
||||
- If `execute_task` times out, first check the host's MCP timeout settings. Changing from `stdio` to HTTP (`sse` or `streamable-http`) does not remove host-side per-call time limits.
|
||||
- `upload_skill` requires a cloud API key; if it fails, the evolved skill is still saved locally.
|
||||
- After every OpenSpace call, **tell the user** what happened: task result, any evolved skills, and your upload decision.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ Exposes the following tools to MCP clients:
|
|||
upload_skill — Upload a local skill to cloud (pre-saved metadata, bot decides visibility)
|
||||
|
||||
Usage:
|
||||
python -m openspace.mcp_server # stdio (default)
|
||||
python -m openspace.mcp_server # auto (TTY -> SSE, MCP host -> stdio)
|
||||
python -m openspace.mcp_server --transport sse # SSE on port 8080
|
||||
python -m openspace.mcp_server --transport streamable-http # Streamable HTTP on port 8080
|
||||
python -m openspace.mcp_server --port 9090 # SSE on custom port
|
||||
|
||||
Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth.py``.
|
||||
|
|
@ -900,14 +901,77 @@ def run_mcp_server() -> None:
|
|||
"""Console-script entry point for ``openspace-mcp``."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
|
||||
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
args = parser.parse_args()
|
||||
def _port_flag_was_set(argv: list[str]) -> bool:
|
||||
return any(arg == "--port" or arg.startswith("--port=") for arg in argv)
|
||||
|
||||
if args.transport == "sse":
|
||||
mcp.run(transport="sse", sse_params={"port": args.port})
|
||||
def _parse_port_from_env(default: int = 8080) -> int:
|
||||
raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip()
|
||||
if not raw_port:
|
||||
return default
|
||||
try:
|
||||
return int(raw_port)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.",
|
||||
raw_port,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
|
||||
def _parse_host_from_env(default: str = "127.0.0.1") -> str:
|
||||
return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default
|
||||
|
||||
def _resolve_transport(requested_transport: str, argv: list[str]) -> str:
|
||||
if requested_transport in ("stdio", "sse", "streamable-http"):
|
||||
return requested_transport
|
||||
|
||||
env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower()
|
||||
if env_transport:
|
||||
if env_transport in ("stdio", "sse", "streamable-http"):
|
||||
return env_transport
|
||||
logger.warning(
|
||||
"Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.",
|
||||
env_transport,
|
||||
)
|
||||
|
||||
# Treat an explicit port override as an HTTP/SSE intent. This keeps the
|
||||
# CLI behavior aligned with the usage examples above.
|
||||
if _port_flag_was_set(argv):
|
||||
return "sse"
|
||||
|
||||
stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
||||
stdout_is_tty = _real_stdout.isatty()
|
||||
return "sse" if stdin_is_tty and stdout_is_tty else "stdio"
|
||||
|
||||
argv = sys.argv[1:]
|
||||
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["auto", "stdio", "sse", "streamable-http"],
|
||||
default="auto",
|
||||
)
|
||||
parser.add_argument("--host", default=_parse_host_from_env())
|
||||
parser.add_argument("--port", type=int, default=_parse_port_from_env())
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
transport = _resolve_transport(args.transport, argv)
|
||||
|
||||
if transport == "sse":
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
logger.info("Starting OpenSpace MCP server with SSE transport on port %s", args.port)
|
||||
mcp.run(transport="sse")
|
||||
elif transport == "streamable-http":
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
logger.info(
|
||||
"Starting OpenSpace MCP server with streamable HTTP transport on %s:%s",
|
||||
args.host,
|
||||
args.port,
|
||||
)
|
||||
mcp.run(transport="streamable-http")
|
||||
else:
|
||||
logger.info("Starting OpenSpace MCP server with stdio transport")
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue