1use std::collections::BTreeMap;
61
62use serde::Deserialize;
63
64use crate::error::{Result, TuffError};
65use crate::manifest::{
66 CapabilityManifest, CapabilityType, EnvRef, FORMAT_PLACEHOLDER, HeaderRef, McpServerConfig,
67 McpServerMetadata, McpTransport,
68};
69
70pub const DEFAULT_REGISTRY: &str = "https://registry.modelcontextprotocol.io";
72
73const USER_AGENT: &str = concat!("tuff/", env!("CARGO_PKG_VERSION"));
74
75#[derive(Debug, Deserialize)]
76struct SearchResponse {
77 #[serde(default)]
78 servers: Vec<ServerEnvelope>,
79}
80
81#[derive(Debug, Deserialize)]
82struct ServerEnvelope {
83 server: RegistryServer,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct RegistryServer {
90 pub name: String,
91 #[serde(default)]
92 pub description: String,
93 #[serde(default)]
94 pub version: String,
95 #[serde(default)]
96 pub packages: Vec<RegistryPackage>,
97 #[serde(default)]
98 pub remotes: Vec<RegistryRemote>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct RegistryPackage {
104 pub registry_type: String,
105 pub identifier: String,
106 #[serde(default)]
107 pub version: String,
108 #[serde(default)]
109 pub runtime_hint: Option<String>,
110 #[serde(default)]
111 pub transport: Option<RegistryTransport>,
112 #[serde(default)]
113 pub runtime_arguments: Vec<RegistryArgument>,
114 #[serde(default)]
115 pub package_arguments: Vec<RegistryArgument>,
116 #[serde(default)]
117 pub environment_variables: Vec<RegistryVariable>,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct RegistryTransport {
122 #[serde(rename = "type", default)]
123 pub transport_type: String,
124 #[serde(default)]
125 pub url: Option<String>,
126 #[serde(default)]
127 pub headers: Vec<RegistryVariable>,
128}
129
130#[derive(Debug, Clone, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct RegistryRemote {
133 #[serde(rename = "type", default)]
134 pub transport_type: String,
135 #[serde(default)]
136 pub url: String,
137 #[serde(default)]
138 pub headers: Vec<RegistryVariable>,
139}
140
141#[derive(Debug, Clone, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct RegistryArgument {
144 #[serde(rename = "type", default)]
145 pub argument_type: String,
146 #[serde(default)]
148 pub name: Option<String>,
149 #[serde(default)]
150 pub value: Option<String>,
151 #[serde(default)]
152 pub default: Option<String>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct RegistryVariable {
158 pub name: String,
159 #[serde(default)]
160 pub is_required: bool,
161 #[serde(default)]
162 pub description: Option<String>,
163 #[serde(default)]
167 pub value: Option<String>,
168}
169
170pub async fn search(base_url: &str, query: &str, limit: usize) -> Result<Vec<RegistryServer>> {
177 let url = format!(
178 "{}/v0/servers?search={}&limit={limit}&version=latest",
179 base_url.trim_end_matches('/'),
180 urlencode(query)
181 );
182 let response: SearchResponse = get_json(&url).await?;
183 Ok(response
184 .servers
185 .into_iter()
186 .map(|envelope| envelope.server)
187 .collect())
188}
189
190pub async fn fetch(base_url: &str, name: &str) -> Result<Option<RegistryServer>> {
196 let servers = search(base_url, name, 100).await?;
197 Ok(servers.into_iter().find(|server| server.name == name))
198}
199
200async fn get_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
201 let client = reqwest::Client::builder()
202 .user_agent(USER_AGENT)
203 .build()
204 .map_err(|error| {
205 TuffError::source_failed(format!("could not build the registry HTTP client: {error}"))
206 })?;
207 let response = client.get(url).send().await.map_err(|error| {
208 TuffError::source_failed(format!("could not reach the MCP registry: {error}"))
209 })?;
210 let status = response.status();
211 if !status.is_success() {
212 return Err(TuffError::source_failed(format!(
213 "the MCP registry returned {status} for {url}"
214 )));
215 }
216 let body = response.bytes().await.map_err(|error| {
217 TuffError::source_failed(format!("could not read the MCP registry response: {error}"))
218 })?;
219 serde_json::from_slice(&body).map_err(|error| {
220 TuffError::corrupt(format!(
221 "the MCP registry returned a response Tuff could not parse: {error}"
222 ))
223 })
224}
225
226fn urlencode(value: &str) -> String {
229 let mut out = String::with_capacity(value.len());
230 for byte in value.bytes() {
231 match byte {
232 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
233 out.push(byte as char)
234 }
235 _ => out.push_str(&format!("%{byte:02X}")),
236 }
237 }
238 out
239}
240
241const GENERIC_SEGMENTS: &[&str] = &["mcp", "server", "mcp-server", "server-mcp", "main"];
244
245pub fn default_capability_id(name: &str) -> String {
256 let (namespace, last) = match name.rsplit_once('/') {
257 Some((namespace, last)) => (namespace, last),
258 None => ("", name),
259 };
260 let last = sanitize_segment(last);
261 if !GENERIC_SEGMENTS.contains(&last.as_str()) {
262 return last;
263 }
264 let publisher = namespace.rsplit('.').next().unwrap_or_default();
265 let publisher = sanitize_segment(publisher);
266 if publisher.is_empty() {
267 return last;
268 }
269 if last.is_empty() {
270 return publisher;
271 }
272 format!("{publisher}-{last}")
273}
274
275fn sanitize_segment(value: &str) -> String {
276 let cleaned: String = value
277 .chars()
278 .map(|c| {
279 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
280 c
281 } else {
282 '-'
283 }
284 })
285 .collect();
286 cleaned.trim_matches('-').to_string()
287}
288
289pub fn to_manifest(server: &RegistryServer, id: &str) -> Result<CapabilityManifest> {
293 let config = server_config(server)?;
294 crate::manifest::validate_mcp_server(&config).map_err(|error| {
295 TuffError::unsupported(format!(
296 "registry entry '{}' does not describe a server Tuff can install: {error}",
297 server.name
298 ))
299 })?;
300 Ok(CapabilityManifest {
301 id: id.to_string(),
302 version: if server.version.is_empty() {
303 "0.0.0".to_string()
304 } else {
305 server.version.clone()
306 },
307 capability_type: CapabilityType::McpServer,
308 description: server.description.clone(),
309 files: Vec::new(),
310 root: std::path::PathBuf::new(),
311 implementation: None,
312 parameters: None,
313 hook: None,
314 server: Some(config),
315 policy: None,
316 targets: Vec::new(),
317 })
318}
319
320fn server_config(server: &RegistryServer) -> Result<McpServerConfig> {
321 if let Some(package) = server
322 .packages
323 .iter()
324 .find(|package| package.is_stdio())
325 .or_else(|| server.packages.first())
326 {
327 return stdio_config(server, package);
328 }
329 if !server.remotes.is_empty() {
330 let remote = preferred_remote(server)?;
331 return remote_config(server, remote);
332 }
333 Err(TuffError::unsupported(format!(
334 "registry entry '{}' lists no package and no remote endpoint, so there is nothing to launch or connect to",
335 server.name
336 )))
337}
338
339fn stdio_config(server: &RegistryServer, package: &RegistryPackage) -> Result<McpServerConfig> {
340 if !package.is_stdio() {
341 return Err(TuffError::unsupported(format!(
342 "registry entry '{}' only ships a '{}' transport package, which Tuff cannot launch",
343 server.name,
344 package.transport_type()
345 )));
346 }
347 let command = launch_command(package).ok_or_else(|| {
348 TuffError::unsupported(format!(
349 "registry entry '{}' is published as '{}', which Tuff has no launcher for",
350 server.name, package.registry_type
351 ))
352 })?;
353
354 let env: BTreeMap<String, EnvRef> = package
355 .environment_variables
356 .iter()
357 .map(|variable| {
358 (
359 variable.name.clone(),
360 EnvRef {
361 from_env: variable.name.clone(),
362 },
363 )
364 })
365 .collect();
366
367 let mut args = Vec::new();
368 if command == "npx" {
369 args.push("-y".to_string());
370 }
371 if command == "docker" {
372 args.extend(["run".to_string(), "-i".to_string(), "--rm".to_string()]);
373 for name in env.keys() {
374 args.push("-e".to_string());
375 args.push(name.clone());
376 }
377 }
378 for argument in &package.runtime_arguments {
379 args.extend(render_argument(server, argument)?);
380 }
381 args.push(package_reference(package));
382 for argument in &package.package_arguments {
383 args.extend(render_argument(server, argument)?);
384 }
385
386 Ok(McpServerConfig {
387 transport: McpTransport::Stdio,
388 command: Some(command),
389 args,
390 url: None,
391 env,
392 headers: BTreeMap::new(),
395 metadata: Some(McpServerMetadata {
396 tools_summary: None,
397 }),
398 })
399}
400
401fn remote_config(server: &RegistryServer, remote: &RegistryRemote) -> Result<McpServerConfig> {
402 reject_placeholders(server, &remote.url)?;
403 let headers = remote_headers(server, remote)?;
404 Ok(McpServerConfig {
405 transport: McpTransport::Http,
406 command: None,
407 args: Vec::new(),
408 url: Some(remote.url.clone()),
409 env: BTreeMap::new(),
410 headers,
411 metadata: Some(McpServerMetadata {
412 tools_summary: None,
413 }),
414 })
415}
416
417fn preferred_remote(server: &RegistryServer) -> Result<&RegistryRemote> {
426 if let Some(remote) = server
427 .remotes
428 .iter()
429 .find(|remote| remote.transport_type != SSE_TRANSPORT)
430 {
431 return Ok(remote);
432 }
433 Err(TuffError::unsupported(format!(
434 "registry entry '{}' offers only the superseded 'sse' transport, which Tuff does not speak",
435 server.name
436 )))
437}
438
439const SSE_TRANSPORT: &str = "sse";
440
441fn remote_headers(
464 server: &RegistryServer,
465 remote: &RegistryRemote,
466) -> Result<BTreeMap<String, HeaderRef>> {
467 let capability_id = default_capability_id(&server.name);
468 let mut headers = BTreeMap::new();
469
470 for header in remote.headers.iter().filter(|header| header.is_required) {
471 let name = header.name.trim();
472 if name.is_empty() {
473 return Err(TuffError::unsupported(format!(
474 "registry entry '{}' declares a header with no name",
475 server.name
476 )));
477 }
478
479 let reference = match header.value.as_deref() {
480 Some(value) => header_from_template(server, name, value, &capability_id)?,
481 None => HeaderRef {
482 from_env: sanitized_env_name(&format!("{capability_id}_{name}")),
483 format: None,
484 },
485 };
486 headers.insert(name.to_string(), reference);
487 }
488
489 Ok(headers)
490}
491
492fn header_from_template(
495 server: &RegistryServer,
496 header: &str,
497 value: &str,
498 capability_id: &str,
499) -> Result<HeaderRef> {
500 let placeholders = placeholder_names(value);
501 let [placeholder] = placeholders.as_slice() else {
502 let reason = if placeholders.is_empty() {
503 "a literal value, and a Tuff manifest has no field a literal header value can occupy"
504 } else {
505 "a value built from more than one variable, which Tuff cannot express"
506 };
507 return Err(TuffError::unsupported(format!(
508 "registry entry '{}' declares the {header} header with {reason}",
509 server.name
510 )));
511 };
512
513 let qualified = if GENERIC_VARIABLE_NAMES.contains(&placeholder.to_ascii_lowercase().as_str()) {
517 format!("{capability_id}_{placeholder}")
518 } else {
519 placeholder.clone()
520 };
521
522 Ok(HeaderRef {
523 from_env: sanitized_env_name(&qualified),
524 format: Some(value.replacen(&format!("{{{placeholder}}}"), FORMAT_PLACEHOLDER, 1)),
525 })
526}
527
528fn placeholder_names(value: &str) -> Vec<String> {
530 let mut names = Vec::new();
531 let mut rest = value;
532 while let Some(open) = rest.find('{') {
533 let Some(close) = rest[open..].find('}') else {
534 break;
535 };
536 names.push(rest[open + 1..open + close].to_string());
537 rest = &rest[open + close + 1..];
538 }
539 names
540}
541
542const GENERIC_VARIABLE_NAMES: &[&str] = &["api_key", "apikey", "token", "key", "secret", "auth"];
544
545fn sanitized_env_name(raw: &str) -> String {
548 let mut name = String::with_capacity(raw.len());
549 for character in raw.chars() {
550 if character.is_ascii_alphanumeric() {
551 name.push(character.to_ascii_uppercase());
552 } else if !name.ends_with('_') {
553 name.push('_');
554 }
555 }
556 let name = name.trim_matches('_').to_string();
557 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
558 format!("_{name}")
559 } else {
560 name
561 }
562}
563
564pub fn skipped_optional_headers(server: &RegistryServer) -> Vec<String> {
568 if !server.packages.is_empty() {
571 return Vec::new();
572 }
573 let Ok(remote) = preferred_remote(server) else {
574 return Vec::new();
575 };
576 let mut names: Vec<String> = remote
577 .headers
578 .iter()
579 .filter(|header| !header.is_required)
580 .map(|header| header.name.trim().to_string())
581 .filter(|name| !name.is_empty())
582 .collect();
583 names.sort();
584 names.dedup();
585 names
586}
587
588fn launch_command(package: &RegistryPackage) -> Option<String> {
590 if let Some(hint) = package
591 .runtime_hint
592 .as_deref()
593 .map(str::trim)
594 .filter(|hint| !hint.is_empty())
595 {
596 return Some(hint.to_string());
597 }
598 match package.registry_type.as_str() {
599 "npm" => Some("npx".to_string()),
600 "pypi" => Some("uvx".to_string()),
601 "oci" | "docker" => Some("docker".to_string()),
602 "nuget" => Some("dnx".to_string()),
603 _ => None,
604 }
605}
606
607fn package_reference(package: &RegistryPackage) -> String {
609 if package.version.is_empty() {
610 return package.identifier.clone();
611 }
612 let separator = if matches!(package.registry_type.as_str(), "oci" | "docker") {
614 ':'
615 } else {
616 '@'
617 };
618 format!("{}{separator}{}", package.identifier, package.version)
619}
620
621fn render_argument(server: &RegistryServer, argument: &RegistryArgument) -> Result<Vec<String>> {
622 let value = argument
623 .value
624 .as_deref()
625 .or(argument.default.as_deref())
626 .unwrap_or_default();
627 reject_placeholders(server, value)?;
628 if let Some(name) = argument.name.as_deref() {
629 reject_placeholders(server, name)?;
630 if value.is_empty() {
631 return Ok(vec![name.to_string()]);
632 }
633 return Ok(vec![name.to_string(), value.to_string()]);
634 }
635 if value.is_empty() {
636 return Err(TuffError::unsupported(format!(
637 "registry entry '{}' has a positional argument with no value, so Tuff cannot build its command line",
638 server.name
639 )));
640 }
641 Ok(vec![value.to_string()])
642}
643
644fn reject_placeholders(server: &RegistryServer, value: &str) -> Result<()> {
648 if value.contains('{') && value.contains('}') {
649 return Err(TuffError::unsupported(format!(
650 "registry entry '{}' needs a value substituted into '{value}', which Tuff cannot supply",
651 server.name
652 ))
653 .with_hint("install it from a local manifest with the value filled in"));
654 }
655 Ok(())
656}
657
658impl RegistryPackage {
659 fn transport_type(&self) -> &str {
660 self.transport
661 .as_ref()
662 .map(|transport| transport.transport_type.as_str())
663 .unwrap_or("stdio")
664 }
665
666 fn is_stdio(&self) -> bool {
667 self.transport_type() == "stdio"
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 fn npm_package(identifier: &str, version: &str) -> RegistryPackage {
676 RegistryPackage {
677 registry_type: "npm".into(),
678 identifier: identifier.into(),
679 version: version.into(),
680 runtime_hint: None,
681 transport: Some(RegistryTransport {
682 transport_type: "stdio".into(),
683 url: None,
684 headers: Vec::new(),
685 }),
686 runtime_arguments: Vec::new(),
687 package_arguments: Vec::new(),
688 environment_variables: Vec::new(),
689 }
690 }
691
692 fn server(name: &str, packages: Vec<RegistryPackage>) -> RegistryServer {
693 RegistryServer {
694 name: name.into(),
695 description: "A test server.".into(),
696 version: "1.2.3".into(),
697 packages,
698 remotes: Vec::new(),
699 }
700 }
701
702 fn config_of(server: &RegistryServer) -> McpServerConfig {
703 to_manifest(server, "test").unwrap().server.unwrap()
704 }
705
706 #[test]
707 fn an_npm_package_becomes_an_npx_command_pinned_to_its_version() {
708 let config = config_of(&server(
709 "io.github.acme/thing",
710 vec![npm_package("thing", "1.2.3")],
711 ));
712 assert_eq!(config.command.as_deref(), Some("npx"));
713 assert_eq!(config.args, vec!["-y", "thing@1.2.3"]);
715 assert_eq!(config.transport, McpTransport::Stdio);
716 }
717
718 #[test]
719 fn the_command_comes_from_the_package_kind_when_no_hint_is_given() {
720 let mut pypi = npm_package("thing", "1.0.0");
723 pypi.registry_type = "pypi".into();
724 assert_eq!(
725 config_of(&server("a/thing", vec![pypi])).command.as_deref(),
726 Some("uvx")
727 );
728
729 let mut nuget = npm_package("thing", "1.0.0");
730 nuget.registry_type = "nuget".into();
731 assert_eq!(
732 config_of(&server("a/thing", vec![nuget]))
733 .command
734 .as_deref(),
735 Some("dnx")
736 );
737 }
738
739 #[test]
740 fn a_runtime_hint_wins_over_the_package_kind() {
741 let mut package = npm_package("thing", "1.0.0");
742 package.runtime_hint = Some("bunx".into());
743 assert_eq!(
744 config_of(&server("a/thing", vec![package]))
745 .command
746 .as_deref(),
747 Some("bunx")
748 );
749 }
750
751 #[test]
752 fn an_image_is_run_with_docker_and_told_about_its_environment() {
753 let mut package = npm_package("ghcr.io/acme/thing", "2.0.0");
756 package.registry_type = "oci".into();
757 package.environment_variables = vec![RegistryVariable {
758 name: "API_TOKEN".into(),
759 is_required: true,
760 description: None,
761 value: None,
762 }];
763 let config = config_of(&server("a/thing", vec![package]));
764 assert_eq!(config.command.as_deref(), Some("docker"));
765 assert_eq!(
766 config.args,
767 vec![
768 "run",
769 "-i",
770 "--rm",
771 "-e",
772 "API_TOKEN",
773 "ghcr.io/acme/thing:2.0.0"
774 ]
775 );
776 }
777
778 #[test]
779 fn environment_variables_become_references_never_values() {
780 let mut package = npm_package("thing", "1.0.0");
781 package.environment_variables = vec![RegistryVariable {
782 name: "API_TOKEN".into(),
783 is_required: true,
784 description: Some("A token.".into()),
785 value: None,
786 }];
787 let config = config_of(&server("a/thing", vec![package]));
788 assert_eq!(config.env["API_TOKEN"].from_env, "API_TOKEN");
789 }
790
791 #[test]
792 fn arguments_are_ordered_runtime_then_package_then_package_arguments() {
793 let mut package = npm_package("thing", "1.0.0");
794 package.runtime_arguments = vec![RegistryArgument {
795 argument_type: "positional".into(),
796 name: None,
797 value: Some("--quiet".into()),
798 default: None,
799 }];
800 package.package_arguments = vec![
801 RegistryArgument {
802 argument_type: "positional".into(),
803 name: None,
804 value: Some("serve".into()),
805 default: None,
806 },
807 RegistryArgument {
808 argument_type: "named".into(),
809 name: Some("--port".into()),
810 value: Some("8080".into()),
811 default: None,
812 },
813 ];
814 let config = config_of(&server("a/thing", vec![package]));
815 assert_eq!(
816 config.args,
817 vec!["-y", "--quiet", "thing@1.0.0", "serve", "--port", "8080"]
818 );
819 }
820
821 #[test]
822 fn an_entry_needing_a_substituted_value_is_refused_rather_than_guessed() {
823 let mut package = npm_package("thing", "1.0.0");
827 package.package_arguments = vec![RegistryArgument {
828 argument_type: "positional".into(),
829 name: None,
830 value: Some("{directory}".into()),
831 default: None,
832 }];
833 let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
834 assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
835 assert!(error.to_string().contains("{directory}"), "{error}");
836 assert!(error.hint().is_some());
837 }
838
839 fn header(name: &str, is_required: bool, value: Option<&str>) -> RegistryVariable {
840 RegistryVariable {
841 name: name.into(),
842 is_required,
843 description: None,
844 value: value.map(str::to_string),
845 }
846 }
847
848 fn remote_entry(name: &str, headers: Vec<RegistryVariable>) -> RegistryServer {
849 let mut entry = server(name, Vec::new());
850 entry.remotes = vec![RegistryRemote {
851 transport_type: "streamable-http".into(),
852 url: "https://mcp.example.com/v1".into(),
853 headers,
854 }];
855 entry
856 }
857
858 #[test]
861 fn a_header_without_a_documented_value_takes_the_whole_value_from_one_variable() {
862 let entry = remote_entry("a/thing", vec![header("Authorization", true, None)]);
863 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
864
865 let reference = &config.headers["Authorization"];
866 assert_eq!(reference.from_env, "THING_AUTHORIZATION");
867 assert_eq!(reference.format, None);
870 }
871
872 #[test]
873 fn a_documented_template_becomes_the_format_and_names_the_variable() {
874 let entry = remote_entry(
875 "ai.smithery/notion",
876 vec![header(
877 "Authorization",
878 true,
879 Some("Bearer {smithery_api_key}"),
880 )],
881 );
882 let config = to_manifest(&entry, "notion").unwrap().server.unwrap();
883
884 let reference = &config.headers["Authorization"];
885 assert_eq!(reference.from_env, "SMITHERY_API_KEY");
886 assert_eq!(reference.format.as_deref(), Some("Bearer {}"));
887 assert_eq!(reference.render("secret"), "Bearer secret");
888 }
889
890 #[test]
893 fn a_generic_placeholder_name_is_qualified_by_the_capability() {
894 let entry = remote_entry(
895 "ai.bowmark/bowmark",
896 vec![header("Authorization", true, Some("Bearer {api_key}"))],
897 );
898 let config = to_manifest(&entry, "bowmark").unwrap().server.unwrap();
899 assert_eq!(config.headers["Authorization"].from_env, "BOWMARK_API_KEY");
900 }
901
902 #[test]
903 fn a_literal_header_value_is_refused_rather_than_smuggled_into_the_manifest() {
904 let entry = remote_entry(
905 "a/thing",
906 vec![header("Accept", true, Some("application/json"))],
907 );
908 let error = to_manifest(&entry, "thing").unwrap_err();
909 assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
910 assert!(error.to_string().contains("Accept"), "{error}");
911 assert!(error.to_string().contains("literal"), "{error}");
912 }
913
914 #[test]
915 fn a_header_built_from_two_variables_is_refused() {
916 let entry = remote_entry(
917 "a/thing",
918 vec![header("Authorization", true, Some("{scheme} {token}"))],
919 );
920 let error = to_manifest(&entry, "thing").unwrap_err();
921 assert!(error.to_string().contains("more than one"), "{error}");
922 }
923
924 #[test]
927 fn optional_headers_are_left_out_and_named_instead() {
928 let entry = remote_entry(
929 "a/thing",
930 vec![
931 header("Authorization", true, None),
932 header("X-Request-Id", false, None),
933 ],
934 );
935 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
936
937 assert!(config.headers.contains_key("Authorization"));
938 assert!(!config.headers.contains_key("X-Request-Id"));
939 assert_eq!(skipped_optional_headers(&entry), vec!["X-Request-Id"]);
940 }
941
942 #[test]
945 fn streamable_http_is_preferred_and_an_sse_only_entry_is_refused() {
946 let mut entry = remote_entry("a/thing", Vec::new());
947 entry.remotes.insert(
948 0,
949 RegistryRemote {
950 transport_type: "sse".into(),
951 url: "https://mcp.example.com/sse".into(),
952 headers: Vec::new(),
953 },
954 );
955 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
956 assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
957
958 entry.remotes.truncate(1);
959 let error = to_manifest(&entry, "thing").unwrap_err();
960 assert!(error.to_string().contains("sse"), "{error}");
961 }
962
963 #[test]
964 fn a_variable_name_is_made_legal_for_a_shell() {
965 assert_eq!(sanitized_env_name("smithery-api.key"), "SMITHERY_API_KEY");
966 assert_eq!(sanitized_env_name("thing_X-Api-Key"), "THING_X_API_KEY");
967 assert_eq!(sanitized_env_name("_leading_"), "LEADING");
968 assert_eq!(sanitized_env_name("2fa token"), "_2FA_TOKEN");
969 }
970
971 #[test]
972 fn a_remote_without_headers_installs_as_an_http_server() {
973 let mut entry = server("a/thing", Vec::new());
974 entry.remotes = vec![RegistryRemote {
975 transport_type: "streamable-http".into(),
976 url: "https://mcp.example.com/v1".into(),
977 headers: Vec::new(),
978 }];
979 let config = config_of(&entry);
980 assert_eq!(config.transport, McpTransport::Http);
981 assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
982 }
983
984 #[test]
985 fn a_package_kind_with_no_launcher_is_refused() {
986 let mut package = npm_package("thing", "1.0.0");
987 package.registry_type = "mcpb".into();
988 let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
989 assert!(error.to_string().contains("mcpb"), "{error}");
990 }
991
992 #[test]
993 fn an_entry_with_nothing_to_launch_is_refused() {
994 let error = to_manifest(&server("a/thing", Vec::new()), "test").unwrap_err();
995 assert!(
996 error.to_string().contains("no package and no remote"),
997 "{error}"
998 );
999 }
1000
1001 #[test]
1002 fn a_capability_id_keeps_the_publishers_name_when_the_last_segment_is_generic() {
1003 assert_eq!(
1004 default_capability_id("io.github.domdomegg/filesystem-mcp"),
1005 "filesystem-mcp"
1006 );
1007 assert_eq!(default_capability_id("com.notion/mcp"), "notion-mcp");
1009 assert_eq!(
1010 default_capability_id("ai.smithery/server"),
1011 "smithery-server"
1012 );
1013 assert_eq!(default_capability_id("plain-name"), "plain-name");
1014 }
1015
1016 #[test]
1017 fn a_capability_id_never_contains_a_path_separator() {
1018 for name in [
1021 "io.github.owner/deep/nested/name",
1022 "weird name/with spaces",
1023 "a/b",
1024 ] {
1025 let id = default_capability_id(name);
1026 assert!(!id.contains('/'), "{name} produced {id}");
1027 assert!(!id.is_empty(), "{name} produced an empty id");
1028 }
1029 }
1030}