Skip to main content

everruns_integrations_parallel/
lib.rs

1//! Parallel web search and fetch for Everruns agents.
2//!
3//! `everruns-integrations-parallel` is part of the
4//! [Everruns](https://everruns.com) ecosystem. It contributes
5//! [Parallel](https://parallel.ai)'s hosted MCP server so agents get
6//! provider-owned `web_search` and `web_fetch` tools — free by default, with an
7//! optional Parallel API-key connection for authenticated usage.
8//!
9//! # Example
10//!
11//! ```
12//! use everruns_core::capabilities::Capability;
13//! use everruns_integrations_parallel::ParallelCapability;
14//!
15//! let capability = ParallelCapability;
16//! assert_eq!(capability.id(), "parallel_search");
17//! ```
18//!
19//! # Design notes
20//!
21//! - Uses Parallel's hosted MCP server directly, so Everruns gets the
22//!   provider-owned behavior without proxying or reimplementing it.
23//! - Defaults to the free unauthenticated endpoint. Per-capability config can
24//!   require a user-scoped Parallel API-key connection and switch to the
25//!   OAuth-compatible MCP endpoint.
26
27pub mod connection;
28pub mod payments;
29
30use everruns_core::capabilities::{
31    Capability, CapabilityLocalization, CapabilityStatus, IntegrationPlugin,
32};
33use everruns_core::connector::ConnectorPlugin;
34use everruns_core::{McpServerAuthMode, ScopedMcpServer, ScopedMcpServers};
35use serde_json::{Value, json};
36
37use connection::ParallelConnector;
38pub use payments::ParallelPaymentsCapability;
39
40inventory::submit! {
41    IntegrationPlugin {
42        experimental_only: true,
43        feature_flag: None,
44        factory: || Box::new(ParallelCapability),
45    }
46}
47
48// Paid Parallel tools route spend through the core `PaymentAuthority`. Gated by
49// the `machine_payments` internal feature flag (off by default on all envs,
50// including dev, because spend is irreversible) rather than the experimental
51// grade gate, so it can be enabled deliberately in any environment.
52inventory::submit! {
53    IntegrationPlugin {
54        experimental_only: false,
55        feature_flag: Some("machine_payments"),
56        factory: || Box::new(ParallelPaymentsCapability),
57    }
58}
59
60inventory::submit! {
61    ConnectorPlugin {
62        experimental_only: true,
63        factory: || Box::new(ParallelConnector),
64    }
65}
66
67pub const PARALLEL_CAPABILITY_ID: &str = "parallel_search";
68pub const PARALLEL_PROVIDER_ID: &str = "parallel";
69pub const PARALLEL_SERVER_NAME: &str = "Parallel";
70pub const PARALLEL_MCP_URL: &str = "https://search.parallel.ai/mcp";
71pub const PARALLEL_MCP_OAUTH_URL: &str = "https://search.parallel.ai/mcp-oauth";
72
73const CONFIG_AUTH_CONNECTION: &str = "connection";
74const CONFIG_ENDPOINT_OAUTH: &str = "oauth";
75
76pub struct ParallelCapability;
77
78impl ParallelCapability {
79    fn use_connection(config: &serde_json::Value) -> bool {
80        config
81            .get("auth")
82            .and_then(|value| value.as_str())
83            .is_some_and(|auth| auth == CONFIG_AUTH_CONNECTION)
84            || Self::use_oauth_endpoint(config)
85    }
86
87    fn use_oauth_endpoint(config: &serde_json::Value) -> bool {
88        config
89            .get("endpoint")
90            .and_then(|value| value.as_str())
91            .is_some_and(|endpoint| endpoint == CONFIG_ENDPOINT_OAUTH)
92    }
93
94    fn mcp_url(config: &serde_json::Value) -> &'static str {
95        if Self::use_oauth_endpoint(config) {
96            PARALLEL_MCP_OAUTH_URL
97        } else {
98            PARALLEL_MCP_URL
99        }
100    }
101}
102
103impl Capability for ParallelCapability {
104    fn id(&self) -> &str {
105        PARALLEL_CAPABILITY_ID
106    }
107
108    fn name(&self) -> &str {
109        "[Experimental] Parallel"
110    }
111
112    fn description(&self) -> &str {
113        "Search and fetch web content through Parallel MCP. Free by default, with optional Parallel API-key connection for authenticated usage."
114    }
115
116    fn status(&self) -> CapabilityStatus {
117        CapabilityStatus::Available
118    }
119
120    fn icon(&self) -> Option<&str> {
121        Some("search")
122    }
123
124    fn category(&self) -> Option<&str> {
125        Some("Network")
126    }
127
128    fn system_prompt_addition(&self) -> Option<&str> {
129        Some(
130            r#"## Parallel
131
132Use Parallel for current web search and focused URL extraction.
133
134For Parallel tool calls, generate one stable `session_id` for the conversation and reuse it for both search and fetch calls."#,
135        )
136    }
137
138    fn config_schema(&self) -> Option<Value> {
139        Some(json!({
140            "type": "object",
141            "title": "Parallel Settings",
142            "properties": {
143                "auth": {
144                    "type": "string",
145                    "title": "Authentication",
146                    "description": "Free works without setup. API key uses Settings > Connections > Parallel.",
147                    "default": "free",
148                    "oneOf": [
149                        { "const": "free", "title": "Free" },
150                        { "const": "connection", "title": "Parallel API Key" }
151                    ]
152                },
153                "endpoint": {
154                    "type": "string",
155                    "title": "MCP Endpoint",
156                    "description": "OAuth MCP uses https://search.parallel.ai/mcp-oauth and requires a Parallel API key.",
157                    "default": "free",
158                    "oneOf": [
159                        { "const": "free", "title": "Free MCP" },
160                        { "const": "oauth", "title": "OAuth MCP" }
161                    ]
162                }
163            },
164            "allOf": [
165                {
166                    "if": {
167                        "properties": {
168                            "endpoint": { "const": "oauth" }
169                        },
170                        "required": ["endpoint"]
171                    },
172                    "then": {
173                        "properties": {
174                            "auth": { "const": "connection" }
175                        },
176                        "required": ["auth"]
177                    }
178                }
179            ]
180        }))
181    }
182
183    fn config_ui_schema(&self) -> Option<Value> {
184        Some(json!({
185            "ui:submitButtonOptions": { "norender": true },
186            "ui:order": ["auth", "endpoint"],
187            "auth": { "ui:widget": "select" },
188            "endpoint": { "ui:widget": "select" }
189        }))
190    }
191
192    fn validate_config(&self, config: &Value) -> Result<(), String> {
193        if config.is_null() {
194            return Ok(());
195        }
196
197        let object = config
198            .as_object()
199            .ok_or_else(|| "Invalid parallel_search config: expected object".to_string())?;
200
201        for field in object.keys() {
202            if field != "auth" && field != "endpoint" {
203                return Err(format!(
204                    "Invalid parallel_search config: unknown field `{field}`"
205                ));
206            }
207        }
208
209        if let Some(auth) = object.get("auth") {
210            match auth.as_str() {
211                Some("free" | CONFIG_AUTH_CONNECTION) => {}
212                Some(other) => {
213                    return Err(format!(
214                        "Invalid parallel_search config: unsupported auth `{other}`"
215                    ));
216                }
217                None => return Err("Invalid parallel_search config: auth must be a string".into()),
218            }
219        }
220
221        if let Some(endpoint) = object.get("endpoint") {
222            match endpoint.as_str() {
223                Some("free" | CONFIG_ENDPOINT_OAUTH) => {}
224                Some(other) => {
225                    return Err(format!(
226                        "Invalid parallel_search config: unsupported endpoint `{other}`"
227                    ));
228                }
229                None => {
230                    return Err("Invalid parallel_search config: endpoint must be a string".into());
231                }
232            }
233        }
234
235        let auth = object.get("auth").and_then(Value::as_str).unwrap_or("free");
236        let endpoint = object
237            .get("endpoint")
238            .and_then(Value::as_str)
239            .unwrap_or("free");
240        if endpoint == CONFIG_ENDPOINT_OAUTH && auth != CONFIG_AUTH_CONNECTION {
241            return Err(
242                "Invalid parallel_search config: endpoint `oauth` requires auth `connection`"
243                    .into(),
244            );
245        }
246
247        Ok(())
248    }
249
250    fn localizations(&self) -> Vec<CapabilityLocalization> {
251        vec![
252            CapabilityLocalization {
253                locale: "en",
254                name: None,
255                description: None,
256                config_description: Some(
257                    "Choose whether Parallel uses the free endpoint or a Parallel API-key \
258                     connection, and which MCP endpoint it talks to.",
259                ),
260                config_overlay: None,
261            },
262            CapabilityLocalization {
263                locale: "uk",
264                name: Some("[Експериментально] Parallel"),
265                description: Some(
266                    "Шукайте та отримуйте вебвміст через Parallel MCP. Безкоштовно за \
267                     замовчуванням, з опціональним підключенням за API-ключем Parallel для \
268                     автентифікованого використання.",
269                ),
270                config_description: Some(
271                    "Визначає, чи використовує Parallel безкоштовний доступ або підключення з \
272                     API-ключем Parallel, і який ендпоінт MCP застосовується.",
273                ),
274                config_overlay: Some(json!({
275                    "properties": {
276                        "auth": {
277                            "title": "Автентифікація",
278                            "description": "Безкоштовний режим працює без налаштувань. API-ключ використовує Налаштування > Підключення > Parallel.",
279                            "enum_labels": {
280                                "free": "Безкоштовно",
281                                "connection": "API-ключ Parallel"
282                            }
283                        },
284                        "endpoint": {
285                            "title": "Ендпоінт MCP",
286                            "description": "OAuth MCP використовує https://search.parallel.ai/mcp-oauth і потребує API-ключа Parallel.",
287                            "enum_labels": {
288                                "free": "Безкоштовний MCP",
289                                "oauth": "OAuth MCP"
290                            }
291                        }
292                    }
293                })),
294            },
295        ]
296    }
297
298    fn mcp_servers_with_config(&self, config: &serde_json::Value) -> ScopedMcpServers {
299        let mut servers = ScopedMcpServers::default();
300        servers.insert(
301            PARALLEL_SERVER_NAME.to_string(),
302            ScopedMcpServer {
303                url: Self::mcp_url(config).to_string(),
304                auth_mode: if Self::use_connection(config) {
305                    McpServerAuthMode::OAuth
306                } else {
307                    McpServerAuthMode::None
308                },
309                oauth_provider_id: Self::use_connection(config)
310                    .then(|| PARALLEL_PROVIDER_ID.to_string()),
311                ..Default::default()
312            },
313        );
314        servers
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use everruns_core::capabilities::{Capability, collect_capability_mcp_servers};
322    use everruns_core::capability_types::AgentCapabilityConfig;
323    use serde_json::json;
324
325    #[test]
326    fn metadata() {
327        let cap = ParallelCapability;
328        assert_eq!(cap.id(), "parallel_search");
329        assert_eq!(cap.icon(), Some("search"));
330        assert_eq!(cap.category(), Some("Network"));
331        assert!(cap.tools().is_empty());
332        assert!(cap.tool_definitions().is_empty());
333    }
334
335    #[test]
336    fn contributes_free_mcp_server_by_default() {
337        let cap = ParallelCapability;
338        let servers = cap.mcp_servers_with_config(&json!({}));
339        let server = servers.get(PARALLEL_SERVER_NAME).unwrap();
340        assert_eq!(server.url, PARALLEL_MCP_URL);
341        assert_eq!(server.auth_mode, McpServerAuthMode::None);
342        assert_eq!(server.oauth_provider_id, None);
343    }
344
345    #[test]
346    fn config_can_use_connection_on_default_endpoint() {
347        let cap = ParallelCapability;
348        let servers = cap.mcp_servers_with_config(&json!({ "auth": "connection" }));
349        let server = servers.get(PARALLEL_SERVER_NAME).unwrap();
350        assert_eq!(server.url, PARALLEL_MCP_URL);
351        assert_eq!(server.auth_mode, McpServerAuthMode::OAuth);
352        assert_eq!(
353            server.oauth_provider_id.as_deref(),
354            Some(PARALLEL_PROVIDER_ID)
355        );
356    }
357
358    #[test]
359    fn config_can_use_oauth_endpoint() {
360        let cap = ParallelCapability;
361        let servers =
362            cap.mcp_servers_with_config(&json!({ "auth": "connection", "endpoint": "oauth" }));
363        let server = servers.get(PARALLEL_SERVER_NAME).unwrap();
364        assert_eq!(server.url, PARALLEL_MCP_OAUTH_URL);
365        assert_eq!(server.auth_mode, McpServerAuthMode::OAuth);
366        assert_eq!(
367            server.oauth_provider_id.as_deref(),
368            Some(PARALLEL_PROVIDER_ID)
369        );
370    }
371
372    #[test]
373    fn validates_config_values() {
374        let cap = ParallelCapability;
375
376        assert!(
377            cap.validate_config(&json!({ "auth": "connection", "endpoint": "oauth" }))
378                .is_ok()
379        );
380        assert!(
381            cap.validate_config(&json!({ "endpoint": "oauth" }))
382                .is_err()
383        );
384        assert!(
385            cap.validate_config(&json!({ "auth": "free", "endpoint": "oauth" }))
386                .is_err()
387        );
388        assert!(cap.validate_config(&json!({ "auth": "invalid" })).is_err());
389        assert!(cap.validate_config(&json!({ "extra": true })).is_err());
390    }
391
392    #[test]
393    fn localizations_cover_schema_summary_and_uk_name() {
394        let cap = ParallelCapability;
395        assert!(cap.describe_schema(None).is_some());
396        assert_ne!(cap.localized_name(Some("uk-UA")), cap.name());
397    }
398
399    #[test]
400    fn collection_merges_contributed_mcp_servers() {
401        let mut registry = everruns_core::CapabilityRegistry::new();
402        registry.register(ParallelCapability);
403        let configs = vec![AgentCapabilityConfig::new(PARALLEL_CAPABILITY_ID)];
404
405        let servers = collect_capability_mcp_servers(&configs, &registry);
406        assert!(servers.contains_key(PARALLEL_SERVER_NAME));
407    }
408}