diff --git a/examples/parallel-search/README.md b/examples/parallel-search/README.md new file mode 100644 index 0000000..c408321 --- /dev/null +++ b/examples/parallel-search/README.md @@ -0,0 +1,43 @@ +# Parallel web search and page fetching + +Use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +through OpenSpace's existing MCP provider for free web search and page fetching, +with no account or API key. Anonymous searches use Fast mode. The free endpoint +is rate limited and intended for exploration and light use. + +## Run the example + +From the repository root, with Python 3.12 or newer: + +```bash +python -m venv .venv-parallel +source .venv-parallel/bin/activate +cd examples/parallel-search +python -m pip install -r requirements.txt +OPENSPACE_SKIP_DOTENV=1 python search.py +``` + +On Windows, activate with `.venv-parallel\Scripts\activate` and set +`OPENSPACE_SKIP_DOTENV=1` in your shell before running `python search.py`. +The example constrains the MCP SDK to 1.x, which the current OpenSpace +connectors require. + +The script loads `config_mcp.json`, discovers the live tools through +`MCPProvider`, calls `web_search` and `web_fetch`, prints their results, and +closes its sessions. It makes direct tool calls without invoking an LLM or +requiring LLM credentials. Customize the search and the page to fetch: + +```bash +OPENSPACE_SKIP_DOTENV=1 python search.py \ + --query "Python asyncio TaskGroup cancellation" \ + --url https://docs.python.org/3/library/asyncio-task.html +``` + +## Add the server to your OpenSpace configuration + +Merge the `parallel` entry from this example's `mcpServers` into your +`openspace/config/config_mcp.json`, retaining any existing entries. OpenSpace's +HTTP connector negotiates Streamable HTTP with `https://search.parallel.ai/mcp`; +the entry includes an OpenSpace User-Agent and no authentication header. +The example does not change the built-in `web_search` provider. Parallel's +`web_search` and `web_fetch` are external MCP tools. diff --git a/examples/parallel-search/config_mcp.json b/examples/parallel-search/config_mcp.json new file mode 100644 index 0000000..90fd264 --- /dev/null +++ b/examples/parallel-search/config_mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "parallel": { + "url": "https://search.parallel.ai/mcp", + "headers": { + "User-Agent": "OpenSpace/2.0 (+https://github.com/HKUDS/OpenSpace)" + } + } + } +} diff --git a/examples/parallel-search/requirements.txt b/examples/parallel-search/requirements.txt new file mode 100644 index 0000000..c957866 --- /dev/null +++ b/examples/parallel-search/requirements.txt @@ -0,0 +1,3 @@ +# Install from this directory. OpenSpace's MCP connectors use the 1.x SDK. +-e ../.. +mcp>=1.0.0,<2 diff --git a/examples/parallel-search/search.py b/examples/parallel-search/search.py new file mode 100644 index 0000000..05b7480 --- /dev/null +++ b/examples/parallel-search/search.py @@ -0,0 +1,48 @@ +"""Search and fetch through OpenSpace's MCP provider, without an LLM call.""" + +import argparse +import asyncio +import json +from pathlib import Path +from uuid import uuid4 + +from openspace.grounding.backends.mcp import MCPProvider +from openspace.grounding.core.types import ToolStatus + + +async def search_and_fetch(query: str, url: str) -> None: + config = json.loads(Path(__file__).with_name("config_mcp.json").read_text()) + provider = MCPProvider(config) + session_id = uuid4().hex + try: + tools = {tool.name for tool in await provider.list_tools(use_cache=False)} + if not {"web_search", "web_fetch"}.issubset(tools): + raise RuntimeError("Parallel search and fetch tools are unavailable") + + calls = [ + ("web_search", {"objective": query, "search_queries": [query]}), + ("web_fetch", {"urls": [url], "objective": query[:200]}), + ] + for name, parameters in calls: + result = await provider.call_tool( + "mcp-parallel", name, {**parameters, "session_id": session_id} + ) + if result.status != ToolStatus.SUCCESS: + raise RuntimeError(f"{name} failed: {result.error or result.content}") + if not result.content: + raise RuntimeError(f"{name} returned no content") + print(f"\n{name}:\n{result.content}") + finally: + await provider.close_all_sessions() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--query", default="Python asyncio task cancellation") + parser.add_argument("--url", default="https://docs.python.org/3/library/asyncio-task.html") + args = parser.parse_args() + asyncio.run(search_and_fetch(args.query, args.url)) + + +if __name__ == "__main__": + main() diff --git a/openspace/config/README.md b/openspace/config/README.md index 4669cd4..b193d85 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -200,6 +200,10 @@ cp openspace/config/config_mcp.json.example openspace/config/config_mcp.json } ``` +For an HTTP MCP server with free, keyless web search and page fetching, see the +[runnable Parallel Search example](../../examples/parallel-search/README.md). +Merge its server entry into your configuration to keep existing MCP servers. + ## 5. Execution Mode The shell backend supports only local execution. This keeps Bash sandbox