1use anyhow::{Context, Result, bail};
11use mcp_execution_core::Error as CoreError;
12use mcp_execution_core::cli::{ExitCode, OutputFormat};
13use mcp_execution_skill::{
14 GenerateSkillResult, ParsedToolFile, ScanResult, build_skill_context, render_skill_md,
15 scan_tools_directory, validate_server_id,
16};
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19use tracing::{debug, info};
20
21use crate::formatters::format_output;
22
23#[derive(Debug, Serialize)]
25struct SkillWriteResult {
26 success: bool,
27 output_path: String,
28 bytes_written: usize,
29 tool_count: usize,
30 warnings: Vec<String>,
33}
34
35const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
37
38const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
40
41#[allow(clippy::too_many_arguments)]
97pub async fn run(
98 server: String,
99 servers_dir: Option<PathBuf>,
100 output_path: Option<PathBuf>,
101 skill_name: Option<String>,
102 hints: Vec<String>,
103 overwrite: bool,
104 output_format: OutputFormat,
105) -> Result<ExitCode> {
106 debug!("Generating skill for server: {}", server);
107 debug!("Servers directory: {:?}", servers_dir);
108 debug!("Output path: {:?}", output_path);
109 debug!("Skill name: {:?}", skill_name);
110 debug!("Hints: {:?}", hints);
111 debug!("Overwrite: {}", overwrite);
112 debug!("Output format: {}", output_format);
113
114 validate_server_id(&server)
119 .map_err(|e| CoreError::InvalidArgument(format!("Invalid server ID: {e}")))?;
120 info!("Server ID validated: {}", server);
121
122 let tool_dir = resolve_tool_dir(&server, servers_dir.as_deref())?;
123
124 let scan_result = scan_server_tools(&tool_dir, &server).await?;
125
126 let context =
127 prepare_skill_context(&server, &scan_result.tools, hints, skill_name, output_path)?;
128
129 let output_path = PathBuf::from(&context.output_path);
131 if output_path.exists() && !overwrite {
132 bail!(
133 "Output file already exists: {}\n\
134 Use --overwrite to replace existing file.",
135 output_path.display()
136 );
137 }
138
139 let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
141
142 write_skill_md(&rendered, &output_path)?;
143
144 let bytes_written = rendered.len();
145 info!(
146 "SKILL.md written to {} ({} bytes, {} tools)",
147 output_path.display(),
148 bytes_written,
149 context.tool_count,
150 );
151
152 let result = SkillWriteResult {
153 success: true,
154 output_path: output_path.display().to_string(),
155 bytes_written,
156 tool_count: context.tool_count,
157 warnings: scan_result.warnings,
158 };
159
160 let output = format_output(&result, output_format)?;
161 println!("{output}");
162
163 Ok(ExitCode::SUCCESS)
164}
165
166fn resolve_tool_dir(server: &str, servers_dir: Option<&Path>) -> Result<PathBuf> {
173 let servers_base = resolve_servers_dir(servers_dir)?;
175 debug!("Servers base directory: {}", servers_base.display());
176
177 let tool_dir = servers_base.join(server);
179 let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
180 debug!("Server directory: {}", tool_dir.display());
181
182 if !tool_dir.exists() {
184 bail!(
185 "Server directory not found: {}\n\
186 Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
187 tool_dir.display(),
188 server
189 );
190 }
191
192 Ok(tool_dir)
193}
194
195async fn scan_server_tools(tool_dir: &Path, server: &str) -> Result<ScanResult> {
201 info!("Scanning TypeScript files in {}", tool_dir.display());
203 let scan_result = scan_tools_directory(tool_dir)
204 .await
205 .context("Failed to scan tools directory")?;
206
207 if scan_result.tools.is_empty() {
208 bail!(
209 "No TypeScript tool files found in {}\n\
210 Run 'mcp-execution-cli generate --from-config {}' first.",
211 tool_dir.display(),
212 server
213 );
214 }
215
216 info!(
220 "Verified {} tool files against sidecar",
221 scan_result.tools.len()
222 );
223
224 Ok(scan_result)
225}
226
227fn prepare_skill_context(
234 server: &str,
235 tools: &[ParsedToolFile],
236 hints: Vec<String>,
237 skill_name: Option<String>,
238 output_path: Option<PathBuf>,
239) -> Result<GenerateSkillResult> {
240 let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
242
243 let mut context = build_skill_context(server, tools, hints_ref.as_deref());
244
245 if let Some(name) = skill_name {
247 context.skill_name = name;
248 }
249
250 if let Some(path) = output_path {
252 validate_output_path(&path)?;
254 context.output_path = path.display().to_string();
255 } else {
256 let skills_dir = resolve_skills_dir()?;
258 let default_output = skills_dir.join(server).join("SKILL.md");
259 context.output_path = default_output.display().to_string();
260 }
261
262 Ok(context)
263}
264
265fn write_skill_md(rendered: &str, output_path: &Path) -> Result<()> {
272 if let Some(parent) = output_path.parent() {
273 std::fs::create_dir_all(parent)
274 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
275 }
276
277 let tmp_path = output_path.with_added_extension("tmp");
280 std::fs::write(&tmp_path, rendered)
281 .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
282 std::fs::rename(&tmp_path, output_path)
283 .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
284
285 Ok(())
286}
287
288fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
302 if let Some(dir) = servers_dir {
303 if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
305 let home = dirs::home_dir().context("Could not determine home directory")?;
306 Ok(home.join(stripped))
307 } else {
308 Ok(dir.to_path_buf())
309 }
310 } else {
311 let home = dirs::home_dir().context("Could not determine home directory")?;
313 Ok(home.join(DEFAULT_SERVERS_DIR))
314 }
315}
316
317fn resolve_skills_dir() -> Result<PathBuf> {
327 let home = dirs::home_dir().context("Could not determine home directory")?;
328 Ok(home.join(DEFAULT_SKILLS_DIR))
329}
330
331fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
350 if has_path_traversal(path) {
355 return Err(CoreError::SecurityViolation {
356 reason: format!("path traversal detected: {}", path.display()),
357 }
358 .into());
359 }
360
361 if !path.exists() {
363 return Ok(path.to_path_buf());
364 }
365
366 let canonical_path = path
368 .canonicalize()
369 .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
370
371 let canonical_base = if base.exists() {
372 base.canonicalize()
373 .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
374 } else {
375 return Ok(path.to_path_buf());
377 };
378
379 if !canonical_path.starts_with(&canonical_base) {
381 return Err(CoreError::SecurityViolation {
382 reason: format!(
383 "path {} is outside base directory {}",
384 canonical_path.display(),
385 canonical_base.display()
386 ),
387 }
388 .into());
389 }
390
391 Ok(canonical_path)
392}
393
394fn validate_output_path(path: &Path) -> Result<()> {
411 if has_path_traversal(path) {
412 return Err(CoreError::SecurityViolation {
413 reason: format!(
414 "invalid output path (path traversal detected): {}",
415 path.display()
416 ),
417 }
418 .into());
419 }
420 Ok(())
421}
422
423fn has_path_traversal(path: &Path) -> bool {
427 mcp_execution_core::contains_parent_dir(path)
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use mcp_execution_core::metadata::{
434 METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
435 ToolMetadata,
436 };
437 use mcp_execution_core::{ServerId, ToolName};
438 use tempfile::TempDir;
439
440 fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
446 let meta = ServerMetadata {
447 schema_version: METADATA_SCHEMA_VERSION,
448 server_id: ServerId::new(server_id).unwrap(),
449 server_name: server_id.to_string(),
450 server_version: "1.0.0".to_string(),
451 tools: vec![ToolMetadata {
452 name: ToolName::new(tool_name).unwrap(),
453 typescript_name: tool_name.to_string(),
454 category: Some("testing".to_string()),
455 keywords: vec!["test".to_string()],
456 description: Some(format!("Test tool: {tool_name}")),
457 parameters: vec![ParameterMetadata {
458 name: "input".to_string(),
459 typescript_type: "string".to_string(),
460 required: true,
461 description: Some("Test input".to_string()),
462 }],
463 }],
464 };
465
466 let content = serde_json::to_string_pretty(&meta).unwrap();
467 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
468 std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
469 }
470
471 #[test]
472 fn test_resolve_servers_dir_default() {
473 let result = resolve_servers_dir(None);
474 assert!(result.is_ok());
475 let path = result.unwrap();
476 assert!(path.to_string_lossy().contains(".claude/servers"));
477 }
478
479 #[test]
480 fn test_resolve_servers_dir_custom() {
481 let custom = PathBuf::from("/custom/servers");
482 let result = resolve_servers_dir(Some(&custom));
483 assert!(result.is_ok());
484 assert_eq!(result.unwrap(), custom);
485 }
486
487 #[test]
488 fn test_resolve_servers_dir_tilde() {
489 let custom = PathBuf::from("~/custom/servers");
490 let result = resolve_servers_dir(Some(&custom));
491 assert!(result.is_ok());
492 let path = result.unwrap();
493 assert!(!path.to_string_lossy().starts_with('~'));
495 assert!(path.to_string_lossy().contains("custom/servers"));
496 }
497
498 #[test]
499 fn test_validate_path_security_valid() {
500 let temp = TempDir::new().unwrap();
501 let base = temp.path();
502 let subdir = base.join("server");
503 std::fs::create_dir(&subdir).unwrap();
504
505 let result = validate_path_security(&subdir, base);
506 assert!(result.is_ok());
507 }
508
509 #[test]
510 fn test_validate_path_security_traversal() {
511 let temp = TempDir::new().unwrap();
512 let base = temp.path();
513 let evil_path = base.join("..").join("etc").join("passwd");
514
515 let result = validate_path_security(&evil_path, base);
516 assert!(result.is_err());
517 let err = result.unwrap_err();
518 assert!(err.to_string().contains("traversal"));
519 assert!(matches!(
523 err.downcast_ref::<CoreError>(),
524 Some(CoreError::SecurityViolation { .. })
525 ));
526 }
527
528 #[test]
529 fn test_validate_path_security_nonexistent() {
530 let temp = TempDir::new().unwrap();
531 let base = temp.path();
532 let new_path = base.join("new-server");
533
534 let result = validate_path_security(&new_path, base);
536 assert!(result.is_ok());
537 }
538
539 #[test]
540 fn test_resolve_skills_dir() {
541 let result = resolve_skills_dir();
542 assert!(result.is_ok());
543 let path = result.unwrap();
544 assert!(path.to_string_lossy().contains(".claude/skills"));
545 }
546
547 #[test]
548 fn test_has_path_traversal() {
549 assert!(has_path_traversal(Path::new("../etc/passwd")));
551 assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
552 assert!(has_path_traversal(Path::new("foo/../../bar")));
553
554 assert!(!has_path_traversal(Path::new("/etc/passwd")));
556 assert!(!has_path_traversal(Path::new("foo/bar/baz")));
557 assert!(!has_path_traversal(Path::new("./foo/bar")));
558 assert!(!has_path_traversal(Path::new("...")));
559 assert!(!has_path_traversal(Path::new("..foo")));
560 }
561
562 #[test]
563 fn test_validate_output_path_valid() {
564 assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
565 assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
566 assert!(validate_output_path(Path::new("./output.md")).is_ok());
567 }
568
569 #[test]
570 fn test_validate_output_path_traversal() {
571 let result = validate_output_path(Path::new("../../../etc/passwd"));
572 assert!(result.is_err());
573 assert!(result.unwrap_err().to_string().contains("path traversal"));
574
575 let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
576 assert!(result.is_err());
577 }
578
579 #[tokio::test]
580 async fn test_run_output_path_traversal() {
581 let temp = TempDir::new().unwrap();
582 let server_dir = temp.path().join("github");
583 std::fs::create_dir(&server_dir).unwrap();
584 write_meta_sidecar(&server_dir, "github", "test");
585
586 let evil_output = temp
588 .path()
589 .join("..")
590 .join("..")
591 .join("etc")
592 .join("evil.md");
593
594 let result = run(
595 "github".to_string(),
596 Some(temp.path().to_path_buf()),
597 Some(evil_output),
598 None,
599 vec![],
600 false,
601 OutputFormat::Json,
602 )
603 .await;
604
605 assert!(result.is_err());
606 assert!(result.unwrap_err().to_string().contains("path traversal"));
607 }
608
609 #[tokio::test]
610 async fn test_run_invalid_server_id() {
611 let result = run(
612 "INVALID_ID".to_string(), None,
614 None,
615 None,
616 vec![],
617 false,
618 OutputFormat::Json,
619 )
620 .await;
621
622 assert!(result.is_err());
623 let err = result.unwrap_err();
624 assert!(err.to_string().contains("Invalid server ID"));
625 assert!(matches!(
629 err.downcast_ref::<CoreError>(),
630 Some(CoreError::InvalidArgument(_))
631 ));
632 }
633
634 #[tokio::test]
635 async fn test_run_server_not_found() {
636 let temp = TempDir::new().unwrap();
637 let result = run(
638 "nonexistent-server".to_string(),
639 Some(temp.path().to_path_buf()),
640 None,
641 None,
642 vec![],
643 false,
644 OutputFormat::Json,
645 )
646 .await;
647
648 assert!(result.is_err());
649 assert!(
650 result
651 .unwrap_err()
652 .to_string()
653 .contains("Server directory not found")
654 );
655 }
656
657 #[tokio::test]
658 async fn test_run_no_typescript_files() {
659 let temp = TempDir::new().unwrap();
660 let server_dir = temp.path().join("empty-server");
661 std::fs::create_dir(&server_dir).unwrap();
662
663 let result = run(
666 "empty-server".to_string(),
667 Some(temp.path().to_path_buf()),
668 None,
669 None,
670 vec![],
671 false,
672 OutputFormat::Json,
673 )
674 .await;
675
676 assert!(result.is_err());
677 assert!(
678 result
679 .unwrap_err()
680 .to_string()
681 .contains("Failed to scan tools directory")
682 );
683 }
684
685 #[tokio::test]
686 async fn test_run_with_valid_typescript_files() {
687 let temp = TempDir::new().unwrap();
688 let server_dir = temp.path().join("test-server");
689 std::fs::create_dir(&server_dir).unwrap();
690 write_meta_sidecar(&server_dir, "test-server", "test_tool");
691
692 let output_path = temp.path().join("SKILL.md");
693
694 let result = run(
695 "test-server".to_string(),
696 Some(temp.path().to_path_buf()),
697 Some(output_path.clone()),
698 None,
699 vec![],
700 false,
701 OutputFormat::Json,
702 )
703 .await;
704
705 assert!(
706 result.is_ok(),
707 "Expected success but got: {:?}",
708 result.err()
709 );
710 assert!(output_path.exists(), "SKILL.md must be written to disk");
711 let content = std::fs::read_to_string(&output_path).unwrap();
712 assert!(
713 content.starts_with("---\n"),
714 "SKILL.md must start with YAML frontmatter"
715 );
716 }
717
718 #[tokio::test]
719 async fn test_run_with_orphan_ts_file_succeeds() {
720 let temp = TempDir::new().unwrap();
724 let server_dir = temp.path().join("test-server");
725 std::fs::create_dir(&server_dir).unwrap();
726 write_meta_sidecar(&server_dir, "test-server", "test_tool");
727 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
728
729 let output_path = temp.path().join("SKILL.md");
730
731 let result = run(
732 "test-server".to_string(),
733 Some(temp.path().to_path_buf()),
734 Some(output_path.clone()),
735 None,
736 vec![],
737 false,
738 OutputFormat::Json,
739 )
740 .await;
741
742 assert!(
743 result.is_ok(),
744 "an orphaned .ts file must not fail the run: {:?}",
745 result.err()
746 );
747 assert!(output_path.exists(), "SKILL.md must still be written");
748 }
749
750 #[test]
751 fn test_skill_write_result_json_includes_warnings() {
752 let result = SkillWriteResult {
756 success: true,
757 output_path: "/tmp/SKILL.md".to_string(),
758 bytes_written: 42,
759 tool_count: 1,
760 warnings: vec![
761 "'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
762 (re-run 'generate' to refresh the sidecar)"
763 .to_string(),
764 ],
765 };
766
767 let output = format_output(&result, OutputFormat::Json).unwrap();
768
769 assert!(
770 output.contains("\"warnings\""),
771 "JSON output must contain a warnings field: {output}"
772 );
773 assert!(
774 output.contains("orphanTool.ts"),
775 "warnings must name the excluded file: {output}"
776 );
777 }
778
779 #[tokio::test]
780 async fn test_run_with_custom_skill_name() {
781 let temp = TempDir::new().unwrap();
782 let server_dir = temp.path().join("github");
783 std::fs::create_dir(&server_dir).unwrap();
784 write_meta_sidecar(&server_dir, "github", "create_issue");
785
786 let output_path = temp.path().join("SKILL.md");
788
789 let result = run(
790 "github".to_string(),
791 Some(temp.path().to_path_buf()),
792 Some(output_path),
793 Some("github-advanced".to_string()),
794 vec![],
795 false,
796 OutputFormat::Json,
797 )
798 .await;
799
800 assert!(
801 result.is_ok(),
802 "Expected success but got: {:?}",
803 result.err()
804 );
805 }
806
807 #[tokio::test]
808 async fn test_run_with_hints() {
809 let temp = TempDir::new().unwrap();
810 let server_dir = temp.path().join("github");
811 std::fs::create_dir(&server_dir).unwrap();
812 write_meta_sidecar(&server_dir, "github", "list_prs");
813
814 let output_path = temp.path().join("SKILL.md");
816
817 let result = run(
818 "github".to_string(),
819 Some(temp.path().to_path_buf()),
820 Some(output_path),
821 None,
822 vec!["code review".to_string(), "CI/CD".to_string()],
823 false,
824 OutputFormat::Json,
825 )
826 .await;
827
828 assert!(
829 result.is_ok(),
830 "Expected success but got: {:?}",
831 result.err()
832 );
833 }
834
835 #[tokio::test]
836 async fn test_run_output_exists_no_overwrite() {
837 let temp = TempDir::new().unwrap();
838 let server_dir = temp.path().join("github");
839 std::fs::create_dir(&server_dir).unwrap();
840 write_meta_sidecar(&server_dir, "github", "test");
841
842 let output_path = temp.path().join("SKILL.md");
844 std::fs::write(&output_path, "existing content").unwrap();
845
846 let result = run(
847 "github".to_string(),
848 Some(temp.path().to_path_buf()),
849 Some(output_path),
850 None,
851 vec![],
852 false, OutputFormat::Json,
854 )
855 .await;
856
857 assert!(result.is_err());
858 assert!(result.unwrap_err().to_string().contains("already exists"));
859 }
860
861 #[tokio::test]
862 async fn test_run_output_exists_with_overwrite() {
863 let temp = TempDir::new().unwrap();
864 let server_dir = temp.path().join("github");
865 std::fs::create_dir(&server_dir).unwrap();
866 write_meta_sidecar(&server_dir, "github", "test");
867
868 let output_path = temp.path().join("SKILL.md");
870 std::fs::write(&output_path, "existing content").unwrap();
871
872 let result = run(
873 "github".to_string(),
874 Some(temp.path().to_path_buf()),
875 Some(output_path),
876 None,
877 vec![],
878 true, OutputFormat::Json,
880 )
881 .await;
882
883 assert!(
884 result.is_ok(),
885 "Expected success but got: {:?}",
886 result.err()
887 );
888 }
889
890 #[tokio::test]
891 async fn test_run_all_output_formats() {
892 let temp = TempDir::new().unwrap();
893 let server_dir = temp.path().join("test");
894 std::fs::create_dir(&server_dir).unwrap();
895 write_meta_sidecar(&server_dir, "test", "test");
896
897 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
898 let output_path = temp.path().join(format!("SKILL-{format}.md"));
899 let result = run(
900 "test".to_string(),
901 Some(temp.path().to_path_buf()),
902 Some(output_path),
903 None,
904 vec![],
905 false,
906 format,
907 )
908 .await;
909
910 assert!(
911 result.is_ok(),
912 "Format {:?} should succeed: {:?}",
913 format,
914 result.err()
915 );
916 }
917 }
918
919 #[tokio::test]
920 async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
921 let temp = TempDir::new().unwrap();
926 let server_dir = temp.path().join("github");
927 std::fs::create_dir(&server_dir).unwrap();
928
929 let meta = ServerMetadata {
930 schema_version: METADATA_SCHEMA_VERSION,
931 server_id: ServerId::new("github").unwrap(),
932 server_name: "GitHub".to_string(),
933 server_version: "1.0.0".to_string(),
934 tools: vec![
935 ToolMetadata {
936 name: ToolName::new("create_issue").unwrap(),
937 typescript_name: "createIssue".to_string(),
938 category: Some("issues".to_string()),
939 keywords: vec!["create".to_string()],
940 description: Some("Create an issue".to_string()),
941 parameters: vec![ParameterMetadata {
942 name: "title".to_string(),
943 typescript_type: "string".to_string(),
944 required: true,
945 description: Some("Issue title".to_string()),
946 }],
947 },
948 ToolMetadata {
949 name: ToolName::new("list_repos").unwrap(),
950 typescript_name: "listRepos".to_string(),
951 category: Some("repos".to_string()),
952 keywords: vec!["list".to_string()],
953 description: Some("List repos".to_string()),
954 parameters: vec![],
955 },
956 ],
957 };
958 let content = serde_json::to_string_pretty(&meta).unwrap();
959 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
960
961 std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
965 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
968
969 let output_path = temp.path().join("SKILL.md");
970
971 let result = run(
972 "github".to_string(),
973 Some(temp.path().to_path_buf()),
974 Some(output_path.clone()),
975 None,
976 vec![],
977 false,
978 OutputFormat::Json,
979 )
980 .await;
981
982 assert!(
983 result.is_err(),
984 "drifted sidecar must fail instead of silently succeeding"
985 );
986 let err = result.unwrap_err();
987 let message = format!("{err:?}");
990 assert!(
991 message.contains("create_issue") || message.contains("createIssue.ts"),
992 "error must identify the tool/file with the missing .ts: {message}"
993 );
994 assert!(
995 !output_path.exists(),
996 "SKILL.md must not be written when the sidecar is stale"
997 );
998 }
999
1000 #[tokio::test]
1001 async fn test_run_path_traversal_server_id() {
1002 let temp = TempDir::new().unwrap();
1003
1004 let result = run(
1006 "../etc".to_string(),
1007 Some(temp.path().to_path_buf()),
1008 None,
1009 None,
1010 vec![],
1011 false,
1012 OutputFormat::Json,
1013 )
1014 .await;
1015
1016 assert!(result.is_err());
1017 assert!(
1019 result
1020 .unwrap_err()
1021 .to_string()
1022 .contains("Invalid server ID")
1023 );
1024 }
1025}