From 31aa087ac412af9a3bf1fceb252818601ab4b85e Mon Sep 17 00:00:00 2001 From: George Date: Fri, 9 Oct 2026 03:07:18 +0000 Subject: [PATCH] Add optional Parallel Search MCP configuration and registry example --- .../mcp_client/examples/parallel-search.json | 11 ++ crates/mcp_client/examples/parallel_search.rs | 72 +++++++++ crates/mcp_client/src/tests.rs | 143 ++++++++++++++++++ docs/configuration.md | 3 + docs/parallel-search-mcp.md | 50 ++++++ 5 files changed, 279 insertions(+) create mode 100644 crates/mcp_client/examples/parallel-search.json create mode 100644 crates/mcp_client/examples/parallel_search.rs create mode 100644 docs/parallel-search-mcp.md diff --git a/crates/mcp_client/examples/parallel-search.json b/crates/mcp_client/examples/parallel-search.json new file mode 100644 index 00000000..bb9651c1 --- /dev/null +++ b/crates/mcp_client/examples/parallel-search.json @@ -0,0 +1,11 @@ +{ + "servers": { + "parallel": { + "url": "https://search.parallel.ai/mcp", + "headers": { + "User-Agent": "code-assistant/parallel-search-example" + }, + "enabled_tools": ["web_search", "web_fetch"] + } + } +} diff --git a/crates/mcp_client/examples/parallel_search.rs b/crates/mcp_client/examples/parallel_search.rs new file mode 100644 index 00000000..207b9ab1 --- /dev/null +++ b/crates/mcp_client/examples/parallel_search.rs @@ -0,0 +1,72 @@ +//! Keyless search and fetch through the same MCP tool registry used by the app. +//! Run from the repository root: cargo run -p mcp_client --example parallel_search + +use anyhow::{Context, Result, ensure}; +use command_executor::DefaultCommandExecutor; +use mcp_client::{McpServersConfig, register_mcp_tools}; +use serde_json::{Value, json}; +use std::time::Duration; +use tools_core::{registry::ToolRegistry, tool::ToolContext}; + +async fn call(registry: &ToolRegistry, name: &str, mut arguments: Value) -> Result<()> { + let tool = registry + .get(name) + .with_context(|| format!("missing {name}"))?; + let executor = DefaultCommandExecutor; + let mut context = ToolContext { + command_executor: &executor, + tool_id: None, + session_id: None, + permission_handler: None, + extensions: None, + }; + let output = tokio::time::timeout( + Duration::from_secs(60), + tool.invoke(&mut context, &mut arguments), + ) + .await + .context("MCP tool call timed out")??; + let value = output.to_json()?; + ensure!(output.is_success(), "{name}: {value}"); + println!("{name}: {}", value["text"].as_str().unwrap_or_default()); + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let config: McpServersConfig = serde_json::from_str(include_str!("parallel-search.json"))?; + let mut registry = ToolRegistry::new(); + // This registration path passes no OAuth credential store. The example + // config has no Authorization header or environment variable references. + let statuses = tokio::time::timeout( + Duration::from_secs(60), + register_mcp_tools(&mut registry, &config, &["scope:agent"]), + ) + .await + .context("MCP registration timed out")?; + for status in statuses { + status + .result + .map_err(|error| anyhow::anyhow!("{}: {error}", status.server))?; + } + + call( + ®istry, + "mcp__parallel__web_search", + json!({ + "objective": "Find the Rust book's explanation of ownership", + "search_queries": ["Rust book ownership rules"] + }), + ) + .await?; + // Fetch a known documentation URL to demonstrate reading a specific page. + call( + ®istry, + "mcp__parallel__web_fetch", + json!({ + "urls": ["https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html"], + "objective": "Explain the three ownership rules in Rust" + }), + ) + .await +} diff --git a/crates/mcp_client/src/tests.rs b/crates/mcp_client/src/tests.rs index aff08701..59742984 100644 --- a/crates/mcp_client/src/tests.rs +++ b/crates/mcp_client/src/tests.rs @@ -320,6 +320,149 @@ async fn connects_and_calls_over_http() { assert_eq!(rendered, "echo: over http"); } +/// Exercise the shipped configuration through registration and dispatch, while +/// observing the HTTP requests rather than just asserting a header string. +#[tokio::test] +async fn parallel_example_sends_anonymous_identified_requests() { + use crate::{McpServersConfig, McpTransport, register_mcp_tools}; + use axum::{body::Body, extract::Request, middleware::Next}; + use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, + tower::{StreamableHttpServerConfig, StreamableHttpService}, + }; + use std::sync::Mutex; + + struct ParallelFixture; + impl ServerHandler for ParallelFixture { + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items( + ["web_search", "web_fetch", "unrelated"] + .into_iter() + .map(|name| { + McpToolDescriptor::new(name, name, schema(json!({"type": "object"}))) + }) + .collect(), + )) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _: RequestContext, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "{}: {}", + request.name, + json!(request.arguments) + ))]) + .into()) + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let service = StreamableHttpService::new( + || Ok(ParallelFixture), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = + axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn( + move |request: Request, next: Next| { + let captured = captured.clone(); + async move { + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1024 * 1024).await.unwrap(); + captured.lock().unwrap().push(( + parts.uri.path().to_string(), + parts.headers.clone(), + serde_json::from_slice::(&bytes) + .unwrap_or(serde_json::Value::Null), + )); + next.run(Request::from_parts(parts, Body::from(bytes))) + .await + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/mcp", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let mut config: McpServersConfig = + serde_json::from_str(include_str!("../examples/parallel-search.json")).unwrap(); + let McpTransport::Http { url: endpoint, .. } = + &mut config.servers.get_mut("parallel").unwrap().transport + else { + panic!("expected HTTP"); + }; + assert_eq!(endpoint, "https://search.parallel.ai/mcp"); + *endpoint = url; + assert!(config.substitute_env_values(|_| None).is_empty()); + let mut registry = ToolRegistry::new(); + let statuses = register_mcp_tools(&mut registry, &config, &["scope:agent"]).await; + let registered = statuses[0].result.as_ref().unwrap(); + assert_eq!( + registered, + &["mcp__parallel__web_search", "mcp__parallel__web_fetch"] + ); + + let calls = [ + ( + "web_search", + json!({"objective": "Rust ownership", "search_queries": ["Rust ownership rules"]}), + ), + ( + "web_fetch", + json!({"urls": ["https://doc.rust-lang.org/book/"], "objective": "Rust ownership"}), + ), + ]; + for (name, mut params) in calls.clone() { + let output = registry + .get(&format!("mcp__parallel__{name}")) + .unwrap() + .invoke(&mut test_context(), &mut params) + .await + .unwrap(); + assert!(output.is_success()); + assert!( + output.to_json().unwrap()["text"] + .as_str() + .unwrap() + .contains("Rust") + ); + } + let requests = requests.lock().unwrap(); + for (path, headers, _) in requests.iter() { + assert_eq!(path, "/mcp"); + assert_eq!( + headers["user-agent"], + "code-assistant/parallel-search-example" + ); + assert!(!headers.contains_key("authorization")); + } + assert!( + requests + .iter() + .any(|(_, _, body)| body["method"] == "tools/list") + ); + for (name, params) in calls { + assert!( + requests + .iter() + .any(|(_, _, body)| body["method"] == "tools/call" + && body["params"]["name"] == name + && body["params"]["arguments"] == params) + ); + } + server.abort(); +} + /// Serve a minimal HTTP endpoint that answers every request with `401` and a /// `WWW-Authenticate` challenge — the reactive OAuth trigger. Returns its /// `/mcp` URL and the server task (aborted on drop). diff --git a/docs/configuration.md b/docs/configuration.md index 1fc5d0eb..92645332 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -159,6 +159,9 @@ substitution in `env`/`headers` values). Configuration changes apply on the next agent run — no restart required. A running agent keeps the tool set it started with; the next message picks up added, removed or re-configured servers. +For free, keyless web search and page fetching, see the optional +[Parallel Search MCP example](parallel-search-mcp.md). + Each server is reached over one of two transports, selected by the fields you give it — a `command` runs it as a child process over stdio, a `url` connects over HTTP (streamable transport): diff --git a/docs/parallel-search-mcp.md b/docs/parallel-search-mcp.md new file mode 100644 index 00000000..4330cb72 --- /dev/null +++ b/docs/parallel-search-mcp.md @@ -0,0 +1,50 @@ +# Parallel Search MCP + +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +offers web search and page fetching without a Parallel API key. The anonymous +endpoint is free for light use, with lower rate limits than authenticated access. +This optional configuration uses code-assistant's existing Streamable HTTP MCP +client and leaves your model and other MCP servers unchanged. + +## Enable it + +Merge the `parallel` entry from +[`parallel-search.json`](../crates/mcp_client/examples/parallel-search.json) +into the `servers` object in `~/.config/code-assistant/mcp-servers.json`: + +```json +{ + "servers": { + "parallel": { + "url": "https://search.parallel.ai/mcp", + "headers": { + "User-Agent": "code-assistant/parallel-search-example" + }, + "enabled_tools": ["web_search", "web_fetch"] + } + } +} +``` + +Keep any existing server entries. No token, OAuth login, or environment variable +is needed for this endpoint. Configuration changes apply on the next agent run. +The registered tools are `mcp__parallel__web_search` (search objectives and +queries) and `mcp__parallel__web_fetch` (read specific URLs). You can remove the +entry or set `enabled` to `false` to disable it. + +## Run the registry example + +With the [Rust toolchain](https://rustup.rs/) installed, clone this repository +and run from its root: + +```sh +cargo run -p mcp_client --example parallel_search +``` + +Cargo installs the dependencies declared by `mcp_client`. The example loads the +same JSON configuration, discovers the two tools through `register_mcp_tools`, +and invokes them through `ToolRegistry`. It prints search excerpts about Rust +ownership and fetches the Rust book's ownership page. It does not load saved +OAuth credentials or call an LLM. Each registration or tool call is bounded by +a 60-second timeout; connection errors and server-reported tool errors produce +a nonzero exit status.