Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/mcp_client/examples/parallel-search.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
72 changes: 72 additions & 0 deletions crates/mcp_client/examples/parallel_search.rs
Original file line number Diff line number Diff line change
@@ -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(
&registry,
"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(
&registry,
"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
}
143 changes: 143 additions & 0 deletions crates/mcp_client/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaginatedRequestParams>,
_: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
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<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
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::<serde_json::Value>(&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).
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
50 changes: 50 additions & 0 deletions docs/parallel-search-mcp.md
Original file line number Diff line number Diff line change
@@ -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.