diff --git a/integrations/catalog-index.js b/integrations/catalog-index.js index b229b450..6e9bb97a 100644 --- a/integrations/catalog-index.js +++ b/integrations/catalog-index.js @@ -72,13 +72,14 @@ import entry66 from "./catalog/memory.json" with { type: "json" }; import entry67 from "./catalog/mongodb.json" with { type: "json" }; import entry68 from "./catalog/neon.json" with { type: "json" }; import entry69 from "./catalog/obsidian.json" with { type: "json" }; -import entry70 from "./catalog/paypal.json" with { type: "json" }; -import entry71 from "./catalog/playwright.json" with { type: "json" }; -import entry72 from "./catalog/redis.json" with { type: "json" }; -import entry73 from "./catalog/resend.json" with { type: "json" }; -import entry74 from "./catalog/sequential-thinking.json" with { type: "json" }; -import entry75 from "./catalog/superhuman-mail.json" with { type: "json" }; -import entry76 from "./catalog/time.json" with { type: "json" }; +import entry70 from "./catalog/parallel-search.json" with { type: "json" }; +import entry71 from "./catalog/paypal.json" with { type: "json" }; +import entry72 from "./catalog/playwright.json" with { type: "json" }; +import entry73 from "./catalog/redis.json" with { type: "json" }; +import entry74 from "./catalog/resend.json" with { type: "json" }; +import entry75 from "./catalog/sequential-thinking.json" with { type: "json" }; +import entry76 from "./catalog/superhuman-mail.json" with { type: "json" }; +import entry77 from "./catalog/time.json" with { type: "json" }; export const INTEGRATION_CATALOG_ENTRIES = [ entry0, @@ -158,4 +159,5 @@ export const INTEGRATION_CATALOG_ENTRIES = [ entry74, entry75, entry76, + entry77, ]; diff --git a/integrations/catalog/parallel-search.json b/integrations/catalog/parallel-search.json new file mode 100644 index 00000000..b283889f --- /dev/null +++ b/integrations/catalog/parallel-search.json @@ -0,0 +1,26 @@ +{ + "id": "parallel-search", + "name": "Parallel Search", + "description": "Search the web and fetch page content with Parallel Search MCP. Free for light use, with no API key required.", + "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", + "keywords": [ + "search", + "web", + "research", + "fetch", + "extraction" + ], + "connectionOptions": [ + { + "id": "none", + "provider": "mcp", + "transport": { + "kind": "shttp", + "url": "https://search.parallel.ai/mcp" + }, + "auth": { + "strategy": "none" + } + } + ] +} diff --git a/tests/test_catalogs.py b/tests/test_catalogs.py index 730b5323..b4e3849f 100644 --- a/tests/test_catalogs.py +++ b/tests/test_catalogs.py @@ -85,7 +85,12 @@ def test_catalog_entries_have_required_fields(): def test_remote_no_auth_mcp_entries_are_intentionally_public(): - public_remote_mcp_ids = {"cloudflare-docs", "deepwiki", "huggingface"} + public_remote_mcp_ids = { + "cloudflare-docs", + "deepwiki", + "huggingface", + "parallel-search", + } actual = set() for entry in load_catalog_entries("integrations/catalog"): diff --git a/tests/test_live_integration_smoke.py b/tests/test_live_integration_smoke.py index 9ae0dbfd..f89ec610 100644 --- a/tests/test_live_integration_smoke.py +++ b/tests/test_live_integration_smoke.py @@ -24,6 +24,11 @@ Evidence lines are printed as ``[OSS-5193] ...`` and contain only tool names, counts, and provider error codes — never credentials or message content. + +The anonymous Parallel Search smoke test needs no credentials and runs only +when explicitly enabled: + + RUN_PARALLEL_MCP_LIVE=1 uv run pytest tests/test_live_integration_smoke.py -k parallel -s """ import json @@ -33,6 +38,7 @@ import mcp.types import pytest from openhands.sdk.mcp import MCPError, MCPServer, MCPTimeoutError, create_mcp_tools +from openhands.sdk.mcp.definition import MCPToolObservation from openhands_extensions import get_integration_catalog_entry_model @@ -140,6 +146,67 @@ def _first_advertised(tool_names, candidates): ) +@pytest.mark.skipif( + os.environ.get("RUN_PARALLEL_MCP_LIVE") != "1", + reason="set RUN_PARALLEL_MCP_LIVE=1 to run the anonymous Parallel MCP smoke test", +) +def test_parallel_anonymous_search_fetch_and_invalid_arguments(): + option = _installable_option("parallel-search") + assert option.auth.strategy == "none" + assert option.transport.kind == "shttp" + server = MCPServer.model_validate( + {"url": option.transport.url, "transport": "http"} + ) + + with create_mcp_tools({"parallel-search": server}, timeout=HTTP_TIMEOUT) as client: + assert {"web_search", "web_fetch"} <= {tool.name for tool in client.tools} + for name, arguments in ( + ( + "web_search", + { + "objective": "Find the official Python documentation for dictionaries.", + "search_queries": ["site:docs.python.org dictionary tutorial"], + }, + ), + ( + "web_fetch", + {"urls": ["https://docs.python.org/3/tutorial/datastructures.html"]}, + ), + ): + result = client.call_async_from_sync( + client.call_tool_mcp, + name=name, + arguments=arguments, + timeout=HTTP_TIMEOUT, + ) + assert not result.isError + text = "\n".join( + block.text + for block in result.content + if isinstance(block, mcp.types.TextContent) + ) + payload = json.loads(text) + assert payload == result.structuredContent + assert payload["results"] + assert all( + item["url"].startswith(("http://", "https://")) + for item in payload["results"] + ) + assert any(item.get("excerpts") for item in payload["results"]) + observation = MCPToolObservation.from_call_tool_result(name, result) + assert not observation.is_error + assert text in [block.text for block in observation.content] + + invalid = client.call_async_from_sync( + client.call_tool_mcp, + name="web_search", + arguments={"objective": "Find Python documentation"}, + timeout=HTTP_TIMEOUT, + ) + assert invalid.isError + assert MCPToolObservation.from_call_tool_result("web_search", invalid).is_error + + @requires_github def test_github_identity_and_pr_read(): option = _installable_option("github")