everruns_integrations_parallel/
lib.rs1pub 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
48inventory::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, ®istry);
406 assert!(servers.contains_key(PARALLEL_SERVER_NAME));
407 }
408}