diff --git a/README.md b/README.md index 871298d..257e49e 100644 --- a/README.md +++ b/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 diff --git a/README_CN.md b/README_CN.md index 09aee71..6e3b77b 100644 --- a/README_CN.md +++ b/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 diff --git a/openspace/grounding/backends/mcp/transport/connectors/http.py b/openspace/grounding/backends/mcp/transport/connectors/http.py index 099f112..012440f 100644 --- a/openspace/grounding/backends/mcp/transport/connectors/http.py +++ b/openspace/grounding/backends/mcp/transport/connectors/http.py @@ -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 diff --git a/openspace/host_skills/README.md b/openspace/host_skills/README.md index 0a555ed..5faf1fd 100644 --- a/openspace/host_skills/README.md +++ b/openspace/host_skills/README.md @@ -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. \ No newline at end of file +> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly. diff --git a/openspace/host_skills/delegate-task/SKILL.md b/openspace/host_skills/delegate-task/SKILL.md index ce94061..04b2b90 100644 --- a/openspace/host_skills/delegate-task/SKILL.md +++ b/openspace/host_skills/delegate-task/SKILL.md @@ -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. diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index ba4ca31..9730a34 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -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")