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 workflow: None,
314 hook: None,
315 server: Some(config),
316 policy: None,
317 targets: Vec::new(),
318 })
319}
320
321fn server_config(server: &RegistryServer) -> Result<McpServerConfig> {
322 if let Some(package) = server
323 .packages
324 .iter()
325 .find(|package| package.is_stdio())
326 .or_else(|| server.packages.first())
327 {
328 return stdio_config(server, package);
329 }
330 if !server.remotes.is_empty() {
331 let remote = preferred_remote(server)?;
332 return remote_config(server, remote);
333 }
334 Err(TuffError::unsupported(format!(
335 "registry entry '{}' lists no package and no remote endpoint, so there is nothing to launch or connect to",
336 server.name
337 )))
338}
339
340fn stdio_config(server: &RegistryServer, package: &RegistryPackage) -> Result<McpServerConfig> {
341 if !package.is_stdio() {
342 return Err(TuffError::unsupported(format!(
343 "registry entry '{}' only ships a '{}' transport package, which Tuff cannot launch",
344 server.name,
345 package.transport_type()
346 )));
347 }
348 let command = launch_command(package).ok_or_else(|| {
349 TuffError::unsupported(format!(
350 "registry entry '{}' is published as '{}', which Tuff has no launcher for",
351 server.name, package.registry_type
352 ))
353 })?;
354
355 let env: BTreeMap<String, EnvRef> = package
356 .environment_variables
357 .iter()
358 .map(|variable| {
359 (
360 variable.name.clone(),
361 EnvRef {
362 from_env: variable.name.clone(),
363 },
364 )
365 })
366 .collect();
367
368 let mut args = Vec::new();
369 if command == "npx" {
370 args.push("-y".to_string());
371 }
372 if command == "docker" {
373 args.extend(["run".to_string(), "-i".to_string(), "--rm".to_string()]);
374 for name in env.keys() {
375 args.push("-e".to_string());
376 args.push(name.clone());
377 }
378 }
379 for argument in &package.runtime_arguments {
380 args.extend(render_argument(server, argument)?);
381 }
382 args.push(package_reference(package));
383 for argument in &package.package_arguments {
384 args.extend(render_argument(server, argument)?);
385 }
386
387 Ok(McpServerConfig {
388 transport: McpTransport::Stdio,
389 command: Some(command),
390 args,
391 url: None,
392 env,
393 headers: BTreeMap::new(),
396 metadata: Some(McpServerMetadata {
397 tools_summary: None,
398 }),
399 })
400}
401
402fn remote_config(server: &RegistryServer, remote: &RegistryRemote) -> Result<McpServerConfig> {
403 reject_placeholders(server, &remote.url)?;
404 let headers = remote_headers(server, remote)?;
405 Ok(McpServerConfig {
406 transport: McpTransport::Http,
407 command: None,
408 args: Vec::new(),
409 url: Some(remote.url.clone()),
410 env: BTreeMap::new(),
411 headers,
412 metadata: Some(McpServerMetadata {
413 tools_summary: None,
414 }),
415 })
416}
417
418fn preferred_remote(server: &RegistryServer) -> Result<&RegistryRemote> {
427 if let Some(remote) = server
428 .remotes
429 .iter()
430 .find(|remote| remote.transport_type != SSE_TRANSPORT)
431 {
432 return Ok(remote);
433 }
434 Err(TuffError::unsupported(format!(
435 "registry entry '{}' offers only the superseded 'sse' transport, which Tuff does not speak",
436 server.name
437 )))
438}
439
440const SSE_TRANSPORT: &str = "sse";
441
442fn remote_headers(
465 server: &RegistryServer,
466 remote: &RegistryRemote,
467) -> Result<BTreeMap<String, HeaderRef>> {
468 let capability_id = default_capability_id(&server.name);
469 let mut headers = BTreeMap::new();
470
471 for header in remote.headers.iter().filter(|header| header.is_required) {
472 let name = header.name.trim();
473 if name.is_empty() {
474 return Err(TuffError::unsupported(format!(
475 "registry entry '{}' declares a header with no name",
476 server.name
477 )));
478 }
479
480 let reference = match header.value.as_deref() {
481 Some(value) => header_from_template(server, name, value, &capability_id)?,
482 None => HeaderRef {
483 from_env: sanitized_env_name(&format!("{capability_id}_{name}")),
484 format: None,
485 },
486 };
487 headers.insert(name.to_string(), reference);
488 }
489
490 Ok(headers)
491}
492
493fn header_from_template(
496 server: &RegistryServer,
497 header: &str,
498 value: &str,
499 capability_id: &str,
500) -> Result<HeaderRef> {
501 let placeholders = placeholder_names(value);
502 let [placeholder] = placeholders.as_slice() else {
503 let reason = if placeholders.is_empty() {
504 "a literal value, and a Tuff manifest has no field a literal header value can occupy"
505 } else {
506 "a value built from more than one variable, which Tuff cannot express"
507 };
508 return Err(TuffError::unsupported(format!(
509 "registry entry '{}' declares the {header} header with {reason}",
510 server.name
511 )));
512 };
513
514 let qualified = if GENERIC_VARIABLE_NAMES.contains(&placeholder.to_ascii_lowercase().as_str()) {
518 format!("{capability_id}_{placeholder}")
519 } else {
520 placeholder.clone()
521 };
522
523 Ok(HeaderRef {
524 from_env: sanitized_env_name(&qualified),
525 format: Some(value.replacen(&format!("{{{placeholder}}}"), FORMAT_PLACEHOLDER, 1)),
526 })
527}
528
529fn placeholder_names(value: &str) -> Vec<String> {
531 let mut names = Vec::new();
532 let mut rest = value;
533 while let Some(open) = rest.find('{') {
534 let Some(close) = rest[open..].find('}') else {
535 break;
536 };
537 names.push(rest[open + 1..open + close].to_string());
538 rest = &rest[open + close + 1..];
539 }
540 names
541}
542
543const GENERIC_VARIABLE_NAMES: &[&str] = &["api_key", "apikey", "token", "key", "secret", "auth"];
545
546fn sanitized_env_name(raw: &str) -> String {
549 let mut name = String::with_capacity(raw.len());
550 for character in raw.chars() {
551 if character.is_ascii_alphanumeric() {
552 name.push(character.to_ascii_uppercase());
553 } else if !name.ends_with('_') {
554 name.push('_');
555 }
556 }
557 let name = name.trim_matches('_').to_string();
558 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
559 format!("_{name}")
560 } else {
561 name
562 }
563}
564
565pub fn skipped_optional_headers(server: &RegistryServer) -> Vec<String> {
569 if !server.packages.is_empty() {
572 return Vec::new();
573 }
574 let Ok(remote) = preferred_remote(server) else {
575 return Vec::new();
576 };
577 let mut names: Vec<String> = remote
578 .headers
579 .iter()
580 .filter(|header| !header.is_required)
581 .map(|header| header.name.trim().to_string())
582 .filter(|name| !name.is_empty())
583 .collect();
584 names.sort();
585 names.dedup();
586 names
587}
588
589fn launch_command(package: &RegistryPackage) -> Option<String> {
591 if let Some(hint) = package
592 .runtime_hint
593 .as_deref()
594 .map(str::trim)
595 .filter(|hint| !hint.is_empty())
596 {
597 return Some(hint.to_string());
598 }
599 match package.registry_type.as_str() {
600 "npm" => Some("npx".to_string()),
601 "pypi" => Some("uvx".to_string()),
602 "oci" | "docker" => Some("docker".to_string()),
603 "nuget" => Some("dnx".to_string()),
604 _ => None,
605 }
606}
607
608fn package_reference(package: &RegistryPackage) -> String {
610 if package.version.is_empty() {
611 return package.identifier.clone();
612 }
613 let separator = if matches!(package.registry_type.as_str(), "oci" | "docker") {
615 ':'
616 } else {
617 '@'
618 };
619 format!("{}{separator}{}", package.identifier, package.version)
620}
621
622fn render_argument(server: &RegistryServer, argument: &RegistryArgument) -> Result<Vec<String>> {
623 let value = argument
624 .value
625 .as_deref()
626 .or(argument.default.as_deref())
627 .unwrap_or_default();
628 reject_placeholders(server, value)?;
629 if let Some(name) = argument.name.as_deref() {
630 reject_placeholders(server, name)?;
631 if value.is_empty() {
632 return Ok(vec![name.to_string()]);
633 }
634 return Ok(vec![name.to_string(), value.to_string()]);
635 }
636 if value.is_empty() {
637 return Err(TuffError::unsupported(format!(
638 "registry entry '{}' has a positional argument with no value, so Tuff cannot build its command line",
639 server.name
640 )));
641 }
642 Ok(vec![value.to_string()])
643}
644
645fn reject_placeholders(server: &RegistryServer, value: &str) -> Result<()> {
649 if value.contains('{') && value.contains('}') {
650 return Err(TuffError::unsupported(format!(
651 "registry entry '{}' needs a value substituted into '{value}', which Tuff cannot supply",
652 server.name
653 ))
654 .with_hint("install it from a local manifest with the value filled in"));
655 }
656 Ok(())
657}
658
659impl RegistryPackage {
660 fn transport_type(&self) -> &str {
661 self.transport
662 .as_ref()
663 .map(|transport| transport.transport_type.as_str())
664 .unwrap_or("stdio")
665 }
666
667 fn is_stdio(&self) -> bool {
668 self.transport_type() == "stdio"
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn npm_package(identifier: &str, version: &str) -> RegistryPackage {
677 RegistryPackage {
678 registry_type: "npm".into(),
679 identifier: identifier.into(),
680 version: version.into(),
681 runtime_hint: None,
682 transport: Some(RegistryTransport {
683 transport_type: "stdio".into(),
684 url: None,
685 headers: Vec::new(),
686 }),
687 runtime_arguments: Vec::new(),
688 package_arguments: Vec::new(),
689 environment_variables: Vec::new(),
690 }
691 }
692
693 fn server(name: &str, packages: Vec<RegistryPackage>) -> RegistryServer {
694 RegistryServer {
695 name: name.into(),
696 description: "A test server.".into(),
697 version: "1.2.3".into(),
698 packages,
699 remotes: Vec::new(),
700 }
701 }
702
703 fn config_of(server: &RegistryServer) -> McpServerConfig {
704 to_manifest(server, "test").unwrap().server.unwrap()
705 }
706
707 #[test]
708 fn an_npm_package_becomes_an_npx_command_pinned_to_its_version() {
709 let config = config_of(&server(
710 "io.github.acme/thing",
711 vec![npm_package("thing", "1.2.3")],
712 ));
713 assert_eq!(config.command.as_deref(), Some("npx"));
714 assert_eq!(config.args, vec!["-y", "thing@1.2.3"]);
716 assert_eq!(config.transport, McpTransport::Stdio);
717 }
718
719 #[test]
720 fn the_command_comes_from_the_package_kind_when_no_hint_is_given() {
721 let mut pypi = npm_package("thing", "1.0.0");
724 pypi.registry_type = "pypi".into();
725 assert_eq!(
726 config_of(&server("a/thing", vec![pypi])).command.as_deref(),
727 Some("uvx")
728 );
729
730 let mut nuget = npm_package("thing", "1.0.0");
731 nuget.registry_type = "nuget".into();
732 assert_eq!(
733 config_of(&server("a/thing", vec![nuget]))
734 .command
735 .as_deref(),
736 Some("dnx")
737 );
738 }
739
740 #[test]
741 fn a_runtime_hint_wins_over_the_package_kind() {
742 let mut package = npm_package("thing", "1.0.0");
743 package.runtime_hint = Some("bunx".into());
744 assert_eq!(
745 config_of(&server("a/thing", vec![package]))
746 .command
747 .as_deref(),
748 Some("bunx")
749 );
750 }
751
752 #[test]
753 fn an_image_is_run_with_docker_and_told_about_its_environment() {
754 let mut package = npm_package("ghcr.io/acme/thing", "2.0.0");
757 package.registry_type = "oci".into();
758 package.environment_variables = vec![RegistryVariable {
759 name: "API_TOKEN".into(),
760 is_required: true,
761 description: None,
762 value: None,
763 }];
764 let config = config_of(&server("a/thing", vec![package]));
765 assert_eq!(config.command.as_deref(), Some("docker"));
766 assert_eq!(
767 config.args,
768 vec![
769 "run",
770 "-i",
771 "--rm",
772 "-e",
773 "API_TOKEN",
774 "ghcr.io/acme/thing:2.0.0"
775 ]
776 );
777 }
778
779 #[test]
780 fn environment_variables_become_references_never_values() {
781 let mut package = npm_package("thing", "1.0.0");
782 package.environment_variables = vec![RegistryVariable {
783 name: "API_TOKEN".into(),
784 is_required: true,
785 description: Some("A token.".into()),
786 value: None,
787 }];
788 let config = config_of(&server("a/thing", vec![package]));
789 assert_eq!(config.env["API_TOKEN"].from_env, "API_TOKEN");
790 }
791
792 #[test]
793 fn arguments_are_ordered_runtime_then_package_then_package_arguments() {
794 let mut package = npm_package("thing", "1.0.0");
795 package.runtime_arguments = vec![RegistryArgument {
796 argument_type: "positional".into(),
797 name: None,
798 value: Some("--quiet".into()),
799 default: None,
800 }];
801 package.package_arguments = vec![
802 RegistryArgument {
803 argument_type: "positional".into(),
804 name: None,
805 value: Some("serve".into()),
806 default: None,
807 },
808 RegistryArgument {
809 argument_type: "named".into(),
810 name: Some("--port".into()),
811 value: Some("8080".into()),
812 default: None,
813 },
814 ];
815 let config = config_of(&server("a/thing", vec![package]));
816 assert_eq!(
817 config.args,
818 vec!["-y", "--quiet", "thing@1.0.0", "serve", "--port", "8080"]
819 );
820 }
821
822 #[test]
823 fn an_entry_needing_a_substituted_value_is_refused_rather_than_guessed() {
824 let mut package = npm_package("thing", "1.0.0");
828 package.package_arguments = vec![RegistryArgument {
829 argument_type: "positional".into(),
830 name: None,
831 value: Some("{directory}".into()),
832 default: None,
833 }];
834 let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
835 assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
836 assert!(error.to_string().contains("{directory}"), "{error}");
837 assert!(error.hint().is_some());
838 }
839
840 fn header(name: &str, is_required: bool, value: Option<&str>) -> RegistryVariable {
841 RegistryVariable {
842 name: name.into(),
843 is_required,
844 description: None,
845 value: value.map(str::to_string),
846 }
847 }
848
849 fn remote_entry(name: &str, headers: Vec<RegistryVariable>) -> RegistryServer {
850 let mut entry = server(name, Vec::new());
851 entry.remotes = vec![RegistryRemote {
852 transport_type: "streamable-http".into(),
853 url: "https://mcp.example.com/v1".into(),
854 headers,
855 }];
856 entry
857 }
858
859 #[test]
862 fn a_header_without_a_documented_value_takes_the_whole_value_from_one_variable() {
863 let entry = remote_entry("a/thing", vec![header("Authorization", true, None)]);
864 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
865
866 let reference = &config.headers["Authorization"];
867 assert_eq!(reference.from_env, "THING_AUTHORIZATION");
868 assert_eq!(reference.format, None);
871 }
872
873 #[test]
874 fn a_documented_template_becomes_the_format_and_names_the_variable() {
875 let entry = remote_entry(
876 "ai.smithery/notion",
877 vec![header(
878 "Authorization",
879 true,
880 Some("Bearer {smithery_api_key}"),
881 )],
882 );
883 let config = to_manifest(&entry, "notion").unwrap().server.unwrap();
884
885 let reference = &config.headers["Authorization"];
886 assert_eq!(reference.from_env, "SMITHERY_API_KEY");
887 assert_eq!(reference.format.as_deref(), Some("Bearer {}"));
888 assert_eq!(reference.render("secret"), "Bearer secret");
889 }
890
891 #[test]
894 fn a_generic_placeholder_name_is_qualified_by_the_capability() {
895 let entry = remote_entry(
896 "ai.bowmark/bowmark",
897 vec![header("Authorization", true, Some("Bearer {api_key}"))],
898 );
899 let config = to_manifest(&entry, "bowmark").unwrap().server.unwrap();
900 assert_eq!(config.headers["Authorization"].from_env, "BOWMARK_API_KEY");
901 }
902
903 #[test]
904 fn a_literal_header_value_is_refused_rather_than_smuggled_into_the_manifest() {
905 let entry = remote_entry(
906 "a/thing",
907 vec![header("Accept", true, Some("application/json"))],
908 );
909 let error = to_manifest(&entry, "thing").unwrap_err();
910 assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
911 assert!(error.to_string().contains("Accept"), "{error}");
912 assert!(error.to_string().contains("literal"), "{error}");
913 }
914
915 #[test]
916 fn a_header_built_from_two_variables_is_refused() {
917 let entry = remote_entry(
918 "a/thing",
919 vec![header("Authorization", true, Some("{scheme} {token}"))],
920 );
921 let error = to_manifest(&entry, "thing").unwrap_err();
922 assert!(error.to_string().contains("more than one"), "{error}");
923 }
924
925 #[test]
928 fn optional_headers_are_left_out_and_named_instead() {
929 let entry = remote_entry(
930 "a/thing",
931 vec![
932 header("Authorization", true, None),
933 header("X-Request-Id", false, None),
934 ],
935 );
936 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
937
938 assert!(config.headers.contains_key("Authorization"));
939 assert!(!config.headers.contains_key("X-Request-Id"));
940 assert_eq!(skipped_optional_headers(&entry), vec!["X-Request-Id"]);
941 }
942
943 #[test]
946 fn streamable_http_is_preferred_and_an_sse_only_entry_is_refused() {
947 let mut entry = remote_entry("a/thing", Vec::new());
948 entry.remotes.insert(
949 0,
950 RegistryRemote {
951 transport_type: "sse".into(),
952 url: "https://mcp.example.com/sse".into(),
953 headers: Vec::new(),
954 },
955 );
956 let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
957 assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
958
959 entry.remotes.truncate(1);
960 let error = to_manifest(&entry, "thing").unwrap_err();
961 assert!(error.to_string().contains("sse"), "{error}");
962 }
963
964 #[test]
965 fn a_variable_name_is_made_legal_for_a_shell() {
966 assert_eq!(sanitized_env_name("smithery-api.key"), "SMITHERY_API_KEY");
967 assert_eq!(sanitized_env_name("thing_X-Api-Key"), "THING_X_API_KEY");
968 assert_eq!(sanitized_env_name("_leading_"), "LEADING");
969 assert_eq!(sanitized_env_name("2fa token"), "_2FA_TOKEN");
970 }
971
972 #[test]
973 fn a_remote_without_headers_installs_as_an_http_server() {
974 let mut entry = server("a/thing", Vec::new());
975 entry.remotes = vec![RegistryRemote {
976 transport_type: "streamable-http".into(),
977 url: "https://mcp.example.com/v1".into(),
978 headers: Vec::new(),
979 }];
980 let config = config_of(&entry);
981 assert_eq!(config.transport, McpTransport::Http);
982 assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
983 }
984
985 #[test]
986 fn a_package_kind_with_no_launcher_is_refused() {
987 let mut package = npm_package("thing", "1.0.0");
988 package.registry_type = "mcpb".into();
989 let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
990 assert!(error.to_string().contains("mcpb"), "{error}");
991 }
992
993 #[test]
994 fn an_entry_with_nothing_to_launch_is_refused() {
995 let error = to_manifest(&server("a/thing", Vec::new()), "test").unwrap_err();
996 assert!(
997 error.to_string().contains("no package and no remote"),
998 "{error}"
999 );
1000 }
1001
1002 #[test]
1003 fn a_capability_id_keeps_the_publishers_name_when_the_last_segment_is_generic() {
1004 assert_eq!(
1005 default_capability_id("io.github.domdomegg/filesystem-mcp"),
1006 "filesystem-mcp"
1007 );
1008 assert_eq!(default_capability_id("com.notion/mcp"), "notion-mcp");
1010 assert_eq!(
1011 default_capability_id("ai.smithery/server"),
1012 "smithery-server"
1013 );
1014 assert_eq!(default_capability_id("plain-name"), "plain-name");
1015 }
1016
1017 #[test]
1018 fn a_capability_id_never_contains_a_path_separator() {
1019 for name in [
1022 "io.github.owner/deep/nested/name",
1023 "weird name/with spaces",
1024 "a/b",
1025 ] {
1026 let id = default_capability_id(name);
1027 assert!(!id.contains('/'), "{name} produced {id}");
1028 assert!(!id.is_empty(), "{name} produced an empty id");
1029 }
1030 }
1031}