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:
Xu Lingrui 2026-04-07 20:06:01 +08:00 committed by GitHub
parent b0021b46bb
commit 114f06bd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 228 additions and 69 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")