(dir: &Path)
| 272 | } |
| 273 | |
| 274 | fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> { |
| 275 | let mut out = Vec::new(); |
| 276 | let mut seen = std::collections::HashSet::new(); |
| 277 | for path in md_files(dir, &["md"])? { |
| 278 | let content = std::fs::read_to_string(&path) |
| 279 | .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?; |
| 280 | let (front, body) = split_frontmatter(&content); |
| 281 | let front = front.ok_or_else(|| { |
| 282 | CodeError::Context(format!( |
| 283 | "tool {} has no YAML frontmatter (need `kind:`)", |
| 284 | path.display() |
| 285 | )) |
| 286 | })?; |
| 287 | let meta: ToolFront = serde_yaml::from_str(&front) |
| 288 | .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?; |
| 289 | let spec = match meta.kind.as_str() { |
| 290 | "mcp" => { |
| 291 | // The frontmatter's flat fields (transport/command/args/url/…) plus |
| 292 | // `name` deserialize straight into McpServerConfig; the `kind` key is |
| 293 | // ignored by its lenient Deserialize. |
| 294 | let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| { |
| 295 | CodeError::Context(format!( |
| 296 | "tool {} (kind=mcp) is not a valid MCP server config: {e}", |
| 297 | path.display() |
| 298 | )) |
| 299 | })?; |
| 300 | ToolSpec::Mcp(cfg) |
| 301 | } |
| 302 | "script" => { |
| 303 | let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| { |
| 304 | CodeError::Context(format!( |
| 305 | "tool {} (kind=script) frontmatter: {e}", |
| 306 | path.display() |
| 307 | )) |
| 308 | })?; |
| 309 | // Fail closed at load (not at first call), consistent with the |
| 310 | // runtime guards the script runs under: a non-JS source, a path |
| 311 | // that escapes the workspace, or an out-of-range sandbox limit are |
| 312 | // all directory-load errors rather than first-call surprises. |
| 313 | let p = meta.path.to_string_lossy(); |
| 314 | if !(p.ends_with(".js") || p.ends_with(".mjs")) { |
| 315 | return Err(CodeError::Context(format!( |
| 316 | "tool {} (kind=script) path `{p}` must point to a .js or .mjs file", |
| 317 | path.display() |
| 318 | ))); |
| 319 | } |
| 320 | crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| { |
| 321 | CodeError::Context(format!("tool {} (kind=script): {e}", path.display())) |
| 322 | })?; |
| 323 | let limits = |
| 324 | validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| { |
| 325 | CodeError::Context(format!("tool {} (kind=script): {e}", path.display())) |
| 326 | })?; |
| 327 | let description = meta |
| 328 | .description |
| 329 | .map(|d| d.trim().to_string()) |
| 330 | .filter(|d| !d.is_empty()) |
| 331 | .unwrap_or_else(|| body.trim().to_string()); |
no test coverage detected