1use anyhow::{bail, Context, Result};
14use serde_json::{json, Value};
15use std::collections::BTreeSet;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const REST_SCHEMAS: &[(&str, Option<&str>, &str)] = &[
20 ("greet", Some("GreetRequest"), "GreetResponse"),
21 ("echo", Some("EchoRequest"), "EchoResponse"),
22 ("status", None, "StatusResponse"),
23 ("help", None, "HelpResponse"),
24];
25
26const SURFACES: &[&str] = &["api", "cli", "mcp", "web"];
27const AUTH_KINDS: &[&str] = &["api-key", "bearer", "both", "none", "oauth", "other"];
28const TRANSPORTS: &[&str] = &["dual", "http", "stdio"];
29const BINARY_PROFILES: &[&str] = &["local-adapter", "server-full"];
30const PRIMITIVES: &[&str] = &["elicitation", "prompts", "resources", "tools"];
31const DEPLOYMENTS: &[&str] = &["docker", "none", "systemd"];
32const PLUGINS: &[&str] = &["claude", "codex", "gemini"];
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35struct ActionEntry {
36 name: String,
37 description: String,
38 scope: String,
39 doc_scope: String,
40 transport: String,
41 rest_method: Option<String>,
42 rest_path: Option<String>,
43 cost: String,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CheckMode {
48 Check,
49 Write,
50 CheckAndWrite,
51}
52
53impl CheckMode {
54 pub(crate) fn parse(args: &[String], usage: &str) -> Result<Option<Self>> {
64 let mut write = false;
65 let mut check = false;
66 for arg in args {
67 match arg.as_str() {
68 "--write" => write = true,
69 "--check" => check = true,
70 "--help" | "-h" => {
71 println!("{usage}");
72 return Ok(None);
73 }
74 unknown => bail!("unknown option: {unknown}\n\n{usage}"),
75 }
76 }
77 Ok(Some(match (check, write) {
78 (false, false) | (true, false) => Self::Check,
79 (false, true) => Self::Write,
80 (true, true) => Self::CheckAndWrite,
85 }))
86 }
87
88 pub(crate) fn should_check(self) -> bool {
89 matches!(self, Self::Check | Self::CheckAndWrite)
90 }
91
92 pub(crate) fn should_write(self) -> bool {
93 matches!(self, Self::Write | Self::CheckAndWrite)
94 }
95}
96
97pub fn asciicheck(args: &[String]) -> Result<()> {
98 let (fix, files) = parse_asciicheck_args(args)?;
99 let mut has_errors = false;
100 for file in files {
101 has_errors |= lint_utf8_ascii(Path::new(file), fix)?;
102 }
103 if has_errors {
104 bail!("ASCII check failed");
105 }
106 Ok(())
107}
108
109pub fn check_openapi(args: &[String]) -> Result<()> {
110 let Some(mode) =
111 CheckMode::parse(args, "Usage: cargo xtask check-openapi [--write] [--check]")?
112 else {
113 return Ok(());
114 };
115 let root = current_dir()?;
116 let rendered_value = render_openapi(&root)?;
117 let rendered = canonical_json(&rendered_value)?;
118 let out = root.join("docs/generated/openapi.json");
119 let mut failures = validate_openapi(&root, &rendered_value)?;
120
121 if mode.should_write() {
122 if let Some(parent) = out.parent() {
123 fs::create_dir_all(parent)
124 .with_context(|| format!("failed to create {}", parent.display()))?;
125 }
126 fs::write(&out, &rendered).with_context(|| format!("failed to write {}", out.display()))?;
127 println!("wrote {}", relative_display(&root, &out));
128 }
129
130 if mode.should_check() {
131 if !out.exists() {
132 failures.push(
133 "docs/generated/openapi.json is missing; run scripts/check-openapi.py --write"
134 .to_owned(),
135 );
136 } else if read(&out)? != rendered {
137 failures.push(
138 "docs/generated/openapi.json is stale; run scripts/check-openapi.py --write"
139 .to_owned(),
140 );
141 }
142 }
143
144 finish_failures(failures)?;
145 if mode.should_check() {
146 println!("OpenAPI schema is current");
147 }
148 Ok(())
149}
150
151pub fn check_schema_docs(args: &[String]) -> Result<()> {
152 let Some(mode) = CheckMode::parse(
153 args,
154 "Usage: cargo xtask check-schema-docs [--write] [--check]",
155 )?
156 else {
157 return Ok(());
158 };
159 let root = current_dir()?;
160 let doc = root.join("docs/MCP_SCHEMA.md");
161 let rendered = render_schema_docs(&root)?;
162
163 if mode.should_write() {
164 fs::write(&doc, &rendered).with_context(|| format!("failed to write {}", doc.display()))?;
165 println!("wrote {}", relative_display(&root, &doc));
166 }
167
168 let mut failures = Vec::new();
169 if mode.should_check() {
170 if !doc.exists() {
171 failures.push("docs/MCP_SCHEMA.md is missing; run --write".to_owned());
172 } else if read(&doc)? != rendered {
173 failures.push("docs/MCP_SCHEMA.md is stale; run --write".to_owned());
174 }
175 let actions = extract_actions(&root)?;
176 failures.extend(check_schema_mentions(&root, &actions)?);
177 failures.extend(check_schema_scope(&root, &actions)?);
178 }
179
180 finish_failures(failures)?;
181 if mode.should_check() {
182 println!("schema docs are current");
183 }
184 Ok(())
185}
186
187pub fn check_scaffold_intent_contract() -> Result<()> {
188 let root = current_dir()?;
189 let schema = root.join("docs/contracts/scaffold-intent.schema.json");
190 let examples = root.join("docs/contracts/examples");
191
192 validate_scaffold_schema(&schema)?;
193 let mut found = false;
194 for entry in
195 fs::read_dir(&examples).with_context(|| format!("failed to read {}", examples.display()))?
196 {
197 let path = entry?.path();
198 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
199 continue;
200 };
201 if name.starts_with("scaffold-intent-") && name.ends_with(".json") {
202 found = true;
203 let payload = load_json(&path)?;
204 validate_scaffold_payload(&payload, &path)?;
205 }
206 }
207 require(
208 found,
209 format!("{}: no scaffold intent examples found", examples.display()),
210 )?;
211 println!("scaffold intent contract and examples are valid");
212 Ok(())
213}
214
215pub fn check_cargo_generate(args: &[String]) -> Result<()> {
216 crate::cargo_generate::run(args)
217}
218
219fn parse_asciicheck_args(args: &[String]) -> Result<(bool, Vec<&str>)> {
220 let mut fix = false;
221 let mut files = Vec::new();
222 for arg in args {
223 match arg.as_str() {
224 "--fix" => fix = true,
225 "--help" | "-h" => {
226 println!("Usage: cargo xtask asciicheck [--fix] <files>...");
227 return Ok((false, Vec::new()));
228 }
229 file => files.push(file),
230 }
231 }
232 if files.is_empty() {
233 bail!("the following required arguments were not provided: files");
234 }
235 Ok((fix, files))
236}
237
238fn lint_utf8_ascii(filename: &Path, fix: bool) -> Result<bool> {
239 let raw =
240 fs::read(filename).with_context(|| format!("failed to read {}", filename.display()))?;
241 let text = match String::from_utf8(raw.clone()) {
242 Ok(text) => text,
243 Err(exc) => {
244 let offset = exc.utf8_error().valid_up_to();
245 let partial = &raw[..offset];
246 let line = partial.iter().filter(|byte| **byte == b'\n').count() + 1;
247 let col = offset
248 - partial
249 .iter()
250 .rposition(|byte| *byte == b'\n')
251 .map(|index| index + 1)
252 .unwrap_or(0)
253 + 1;
254 println!("{}: UTF-8 decoding error:", filename.display());
255 println!(" byte offset: {offset}");
256 println!(" reason: {}", exc.utf8_error());
257 println!(" location: line {line}, column {col}");
258 return Ok(true);
259 }
260 };
261
262 let mut errors = Vec::new();
263 for (line_index, line) in text.split_inclusive('\n').enumerate() {
264 for (col_index, ch) in line.chars().enumerate() {
265 let codepoint = ch as u32;
266 if matches!(ch, '\n' | '\r' | '\t') {
267 continue;
268 }
269 if !(0x20..=0x7e).contains(&codepoint) && !allowed_unicode(codepoint) {
270 errors.push((line_index + 1, col_index + 1, ch, codepoint));
271 }
272 }
273 }
274
275 if !errors.is_empty() {
276 println!("{}:", filename.display());
277 for (lineno, colno, ch, codepoint) in &errors {
278 println!(
279 " line {lineno}, column {colno}: U+{codepoint:04X} ({})",
280 escape_char(*ch)
281 );
282 }
283 }
284
285 if !errors.is_empty() && fix {
286 let mut replacements = 0usize;
287 let new_contents: String = text
288 .chars()
289 .flat_map(|ch| {
290 if let Some(replacement) = substitution(ch as u32) {
291 replacements += 1;
292 replacement.chars().collect::<Vec<_>>()
293 } else {
294 vec![ch]
295 }
296 })
297 .collect();
298 fs::write(filename, new_contents)
299 .with_context(|| format!("failed to write {}", filename.display()))?;
300 println!(" fixed {replacements} replaceable character(s)");
301 }
302
303 Ok(!errors.is_empty())
304}
305
306fn allowed_unicode(codepoint: u32) -> bool {
307 matches!(
308 codepoint,
309 0x00A7
310 | 0x00D7
311 | 0x2013
312 | 0x2014
313 | 0x2026
314 | 0x20AC
315 | 0x2190
316 | 0x2192
317 | 0x2193
318 | 0x2194
319 | 0x2248
320 | 0x2264
321 | 0x2265
322 | 0x2500
323 | 0x2501
324 | 0x2502
325 | 0x250C
326 | 0x2510
327 | 0x2514
328 | 0x2518
329 | 0x251C
330 | 0x253C
331 | 0x25B2
332 | 0x25B6
333 | 0x25BC
334 | 0x26A0
335 | 0x2713
336 | 0x2717
337 | 0xFE0F
338 )
339}
340
341fn substitution(codepoint: u32) -> Option<&'static str> {
342 match codepoint {
343 0x00A0 | 0x202F => Some(" "),
344 0x2011 | 0x2013 | 0x2014 => Some("-"),
345 0x2018 | 0x2019 => Some("'"),
346 0x201C | 0x201D => Some("\""),
347 0x2026 => Some("..."),
348 _ => None,
349 }
350}
351
352fn escape_char(ch: char) -> String {
353 match ch {
354 '\n' => "\\n".to_owned(),
355 '\r' => "\\r".to_owned(),
356 '\t' => "\\t".to_owned(),
357 '\'' => "\\'".to_owned(),
358 '"' => "\\\"".to_owned(),
359 '\\' => "\\\\".to_owned(),
360 other => other.to_string(),
361 }
362}
363
364fn render_openapi(root: &Path) -> Result<Value> {
365 let entries = action_entries(root)?;
366 let rest_actions: Vec<&ActionEntry> = entries
367 .iter()
368 .filter(|entry| {
369 entry.transport == "Any" && entry.rest_method.is_some() && entry.rest_path.is_some()
370 })
371 .collect();
372 let action_names: Vec<String> = rest_actions
373 .iter()
374 .map(|entry| entry.name.clone())
375 .collect();
376 let version = package_version(root)?;
377 let port = default_mcp_port(root)?;
378
379 let mut paths = serde_json::Map::new();
380 paths.insert(
381 "/health".to_owned(),
382 json!({"get":{"tags":["health"],"summary":"Liveness probe","operationId":"getHealth","security":[],"responses":{"200":{"description":"Server is alive","content":{"application/json":{"schema":schema_ref("HealthResponse"),"examples":{"ok":{"value":{"status":"ok"}}}}}}}}}),
383 );
384 paths.insert(
385 "/openapi.json".to_owned(),
386 json!({"get":{"tags":["health"],"summary":"OpenAPI schema","operationId":"getOpenApiSchema","security":[],"responses":{"200":{"description":"Generated OpenAPI schema for the REST surface","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}}),
387 );
388 paths.insert(
389 "/status".to_owned(),
390 json!({"get":{"tags":["health"],"summary":"Local runtime status","operationId":"getLocalStatus","security":[],"responses":{"200":{"description":"Runtime status with secrets redacted","content":{"application/json":{"schema":schema_ref("StatusResponse")}}},"500":{"$ref":"#/components/responses/InternalError"}}}}),
391 );
392 paths.insert(
393 "/v1/capabilities".to_owned(),
394 json!({"get":{"tags":["capabilities"],"summary":"Direct REST route inventory","operationId":"getCapabilities","security":[{"BearerAuth":[]},{}],"responses":{"200":{"description":"Supported direct REST routes and metadata","content":{"application/json":{"schema":schema_ref("CapabilitiesResponse")}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}}),
395 );
396 paths.insert(
397 "/v1/providers".to_owned(),
398 json!({"get":{"tags":["provider-tools"],"summary":"Live provider inventory","operationId":"getProviders","security":[{"BearerAuth":[]},{}],"responses":{"200":{"description":"Live provider catalog, including dropped provider tools and MCP primitives","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}}),
399 );
400 paths.insert(
401 "/v1/tools/{action}".to_owned(),
402 json!({"post":{"tags":["provider-tools"],"summary":"Run a provider tool","description":"Generic REST route for dropped provider tools. Tools with custom REST overlays may also expose dedicated direct routes.","operationId":"runProviderTool","security":[{"BearerAuth":[]},{}],"parameters":[{"name":"action","in":"path","required":true,"description":"Provider tool action name","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Provider tool response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Unknown action or surface not exposed","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},"500":{"$ref":"#/components/responses/InternalError"}}}}),
403 );
404
405 for action in &rest_actions {
406 let Some((request_schema, response_schema)) = rest_schemas(&action.name) else {
407 continue;
408 };
409 let method = action
410 .rest_method
411 .as_deref()
412 .expect("rest actions are filtered to require method")
413 .to_ascii_lowercase();
414 let path = action
415 .rest_path
416 .as_deref()
417 .expect("rest actions are filtered to require path");
418 let mut operation = json!({
419 "tags": ["direct-rest"],
420 "summary": format!("Run {}", action.name),
421 "description": "Direct REST route over the shared service layer.",
422 "operationId": format!("{method}{}", title_no_underscore(&action.name)),
423 "security": [{"BearerAuth":[]},{}],
424 "responses": {
425 "200": {"description": format!("{} result", action.name), "content": {"application/json": {"schema": schema_ref(response_schema)}}},
426 "400": {"$ref":"#/components/responses/BadRequest"},
427 "401": {"$ref":"#/components/responses/Unauthorized"},
428 "403": {"$ref":"#/components/responses/Forbidden"},
429 "500": {"$ref":"#/components/responses/InternalError"}
430 }
431 });
432 if let Some(request_schema) = request_schema {
433 operation["requestBody"] = json!({
434 "required": true,
435 "content": {
436 "application/json": {
437 "schema": schema_ref(request_schema),
438 "examples": { action.name.clone(): { "value": param_example(&action.name) } }
439 }
440 }
441 });
442 }
443 paths.insert(path.to_owned(), json!({method: operation}));
444 }
445
446 let direct_rest_routes = rest_actions
447 .iter()
448 .map(|action| {
449 (
450 action.name.clone(),
451 json!({
452 "method": action.rest_method.as_deref().unwrap_or_default().to_uppercase(),
453 "path": action.rest_path.as_deref().unwrap_or_default(),
454 }),
455 )
456 })
457 .collect::<serde_json::Map<_, _>>();
458 let action_costs = entries
459 .iter()
460 .map(|entry| (entry.name.clone(), json!(entry.cost)))
461 .collect::<serde_json::Map<_, _>>();
462 let mcp_only: Vec<String> = entries
463 .iter()
464 .filter(|entry| entry.transport == "McpOnly")
465 .map(|entry| entry.name.clone())
466 .collect();
467
468 Ok(json!({
469 "openapi": "3.1.0",
470 "info": {
471 "title": "Soma MCP REST API",
472 "version": version,
473 "description": "Generated OpenAPI schema for Soma's direct REST surface. Auth modes: loopback/trusted-gateway deployments may have no local auth; mounted bearer mode uses SOMA_MCP_TOKEN; OAuth mode uses bearer JWTs. REST actions require their action-specific scopes when auth is mounted.",
474 "contact": {
475 "name": "dinglebear.ai",
476 "url": "https://dinglebear.ai"
477 },
478 "license": {
479 "name": "MIT",
480 "url": "https://github.com/dinglebear-ai/soma/blob/main/LICENSE"
481 }
482 },
483 "servers": [
484 {"url": format!("http://localhost:{port}"),"description":"Default local development server"},
485 {
486 "url": "https://{host}",
487 "description": "Reverse-proxied Soma deployment",
488 "variables": {
489 "host": {
490 "default": "soma.dinglebear.ai",
491 "description": "Public Soma host configured with SOMA_MCP_PUBLIC_URL"
492 }
493 }
494 }
495 ],
496 "externalDocs": {
497 "description": "Soma documentation",
498 "url": "https://github.com/dinglebear-ai/soma/tree/main/docs"
499 },
500 "tags": [
501 {"name":"health","description":"Unauthenticated runtime probes"},
502 {"name":"capabilities","description":"REST route inventory"},
503 {"name":"direct-rest","description":"Typed REST routes"},
504 {"name":"provider-tools","description":"Dynamic provider inventory and generic tool execution"}
505 ],
506 "paths": paths,
507 "components": {
508 "securitySchemes": {"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"opaque","description":"Static bearer token in bearer mode; OAuth mode also uses bearer JWTs. Loopback and trusted-gateway modes may not require local auth."}},
509 "schemas": openapi_schemas(action_names.clone()),
510 "responses": {
511 "BadRequest":{"description":"Validation error","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
512 "Unauthorized":{"description":"Missing or invalid authentication","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
513 "Forbidden":{"description":"Authenticated request lacks the required scope","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
514 "InternalError":{"description":"Internal server error","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}}
515 }
516 },
517 "x-soma": {
518 "publisher": {
519 "name": "dinglebear.ai",
520 "url": "https://dinglebear.ai"
521 },
522 "homepage": "https://soma.dinglebear.ai",
523 "repository": "https://github.com/dinglebear-ai/soma",
524 "support": "https://github.com/dinglebear-ai/soma/issues",
525 "security_policy": "https://github.com/dinglebear-ai/soma/security/policy",
526 "keywords": [
527 "mcp",
528 "mcp-server",
529 "model-context-protocol",
530 "rmcp",
531 "rust",
532 "agent-tools",
533 "ai-agents",
534 "provider-runtime",
535 "providers",
536 "developer-tools",
537 "automation",
538 "openapi",
539 "docker",
540 "cli",
541 "server-runtime"
542 ],
543 "source": "scripts/check-openapi.py",
544 "action_metadata": "crates/soma/domain/src/actions.rs",
545 "preferred_rest_style": "direct_routes",
546 "binary": "soma",
547 "server_binary": "soma",
548 "node_package": "soma-rmcp",
549 "oci_image": format!("ghcr.io/dinglebear-ai/soma:{version}"),
550 "mcp_registry": "server.json",
551 "provider_directory_env": "SOMA_PROVIDER_DIR",
552 "auth_modes": ["loopback-dev", "bearer", "oauth", "trusted-gateway"],
553 "transports": ["stdio", "streamable-http"],
554 "surfaces": ["mcp", "cli", "rest", "web", "docker", "plugins"],
555 "documentation": {
556 "quickstart": "docs/QUICKSTART.md",
557 "configuration": "docs/CONFIG.md",
558 "environment": "docs/ENV.md",
559 "provider_surfaces": "docs/generated/provider-surfaces.md"
560 },
561 "rest_actions": action_names,
562 "direct_rest_routes": direct_rest_routes,
563 "action_costs": action_costs,
564 "mcp_only_actions": mcp_only
565 }
566 }))
567}
568
569fn openapi_schemas(action_names: Vec<String>) -> Value {
570 json!({
571 "ActionName":{"type":"string","enum":action_names,"description":"REST-capable action names from crates/soma/domain/src/actions.rs."},
572 "GreetRequest":{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string","description":"Name to greet. Omit to greet the world."}}},
573 "EchoRequest":{"type":"object","additionalProperties":false,"required":["message"],"properties":{"message":{"type":"string","minLength":1,"description":"Message to echo back. Must not be empty."}}},
574 "ActionResponse":{"oneOf":[schema_ref("GreetResponse"),schema_ref("EchoResponse"),schema_ref("StatusResponse"),schema_ref("HelpResponse"),schema_ref("RestTruncationResponse")]},
575 "GreetResponse":{"type":"object","required":["greeting","target"],"properties":{"greeting":{"type":"string"},"target":{"type":"string"},"server":{"type":"string"}},"additionalProperties":true},
576 "EchoResponse":{"type":"object","required":["echo"],"properties":{"echo":{"type":"string"}},"additionalProperties":true},
577 "StatusResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string"},"note":{"type":"string"},"server":{"type":"string"},"version":{"type":"string"},"transport":{"type":"string"}},"additionalProperties":true},
578 "HealthResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string","const":"ok"}},"additionalProperties":false},
579 "CapabilitiesResponse":{"type":"object","required":["server","version","preferred_rest_style","supported_routes","routes"],"properties":{"server":{"type":"string"},"version":{"type":"string"},"preferred_rest_style":{"type":"string","const":"direct_routes"},"supported_routes":{"type":"array","items":{"type":"string"}},"routes":{"type":"array","items":schema_ref("RestRoute")}},"additionalProperties":false},
580 "RestRoute":{"type":"object","required":["method","path","auth","description"],"properties":{"method":{"type":"string"},"path":{"type":"string"},"action":{"type":["string","null"]},"auth":{"type":"string"},"description":{"type":"string"}},"additionalProperties":false},
581 "HelpResponse":{"type":"object","required":["actions","mcp_only_actions","usage","examples"],"properties":{"actions":{"type":"array","items":schema_ref("ActionName")},"mcp_only_actions":{"type":"array","items":{"type":"string"}},"usage":{"type":"string"},"examples":{"type":"object","additionalProperties":true}},"additionalProperties":true},
582 "ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}},"additionalProperties":false},
583 "RestTruncationResponse":{"type":"object","required":["truncated","error","max_response_bytes","hint"],"properties":{"truncated":{"type":"boolean","const":true},"error":{"type":"string","const":"response exceeded REST response size limit"},"max_response_bytes":{"type":"integer","minimum":1},"hint":{"type":"string"}},"additionalProperties":false}
584 })
585}
586
587fn validate_openapi(root: &Path, value: &Value) -> Result<Vec<String>> {
588 let mut failures = Vec::new();
589 if value.get("openapi").and_then(Value::as_str) != Some("3.1.0") {
590 failures.push("OpenAPI version must be 3.1.0".to_owned());
591 }
592 for path in required_openapi_paths(root)? {
593 if value
594 .pointer(&format!("/paths/{}", escape_pointer(&path)))
595 .is_none()
596 {
597 failures.push(format!("missing path {path}"));
598 }
599 }
600 if let Some(paths) = value.get("paths").and_then(Value::as_object) {
601 for (path, methods) in paths {
602 if let Some(methods) = methods.as_object() {
603 for (method, operation) in methods {
604 if operation.get("operationId").is_none_or(Value::is_null) {
605 failures.push(format!(
606 "{} {path} is missing operationId",
607 method.to_uppercase()
608 ));
609 }
610 }
611 }
612 }
613 }
614
615 let entries = action_entries(root)?;
616 if entries.len() != action_spec_count(root)? {
617 failures.push(format!(
618 "ActionSpec parser drifted: parsed {} entries from {} specs",
619 entries.len(),
620 action_spec_count(root)?
621 ));
622 }
623 let expected: Vec<String> = entries
624 .iter()
625 .filter(|entry| entry.transport == "Any")
626 .map(|entry| entry.name.clone())
627 .collect();
628 let action_enum = value
629 .pointer("/components/schemas/ActionName/enum")
630 .cloned()
631 .unwrap_or(Value::Null);
632 if action_enum != json!(expected) {
633 failures.push(format!(
634 "ActionName enum drifted: expected {expected:?}, got {action_enum}"
635 ));
636 }
637 let missing_route_metadata: Vec<String> = entries
638 .iter()
639 .filter(|entry| entry.transport == "Any")
640 .filter(|entry| entry.rest_method.is_none() || entry.rest_path.is_none())
641 .map(|entry| entry.name.clone())
642 .collect();
643 if !missing_route_metadata.is_empty() {
644 failures.push(format!(
645 "ACTION_SPECS entries are missing REST route metadata: {missing_route_metadata:?}"
646 ));
647 }
648 for name in entries
649 .iter()
650 .filter(|entry| entry.transport == "McpOnly")
651 .map(|entry| &entry.name)
652 {
653 if action_enum
654 .as_array()
655 .is_some_and(|items| items.iter().any(|item| item.as_str() == Some(name)))
656 {
657 failures.push(format!(
658 "MCP-only action {name:?} must not appear in REST ActionName enum"
659 ));
660 }
661 }
662 if value.pointer("/x-soma/rest_actions") != Some(&json!(expected)) {
663 failures.push(format!(
664 "x-soma rest_actions drifted: expected {expected:?}, got {}",
665 value
666 .pointer("/x-soma/rest_actions")
667 .unwrap_or(&Value::Null)
668 ));
669 }
670 let expected_mcp_only: Vec<String> = entries
671 .iter()
672 .filter(|entry| entry.transport == "McpOnly")
673 .map(|entry| entry.name.clone())
674 .collect();
675 if value.pointer("/x-soma/mcp_only_actions") != Some(&json!(expected_mcp_only)) {
676 failures.push("x-soma mcp_only_actions drifted".to_owned());
677 }
678 for action in entries
679 .iter()
680 .filter(|entry| entry.transport == "Any")
681 .filter(|entry| entry.rest_method.is_some() && entry.rest_path.is_some())
682 {
683 let method = action.rest_method.as_deref().unwrap().to_ascii_lowercase();
684 let path = action.rest_path.as_deref().unwrap();
685 if !expected.iter().any(|expected| expected == &action.name) {
686 continue;
687 }
688 if value
689 .pointer(&format!("/paths/{}/{}", escape_pointer(path), method))
690 .is_none()
691 {
692 failures.push(format!(
693 "missing direct REST operation {} {path}",
694 method.to_uppercase()
695 ));
696 }
697 }
698 if value.pointer("/paths/~1v1~1example").is_some() {
699 failures.push("/v1/soma must not be present; REST uses direct routes only".to_owned());
700 }
701 if value.pointer("/paths/~1v1~1capabilities/get/responses/200/content/application~1json/schema")
702 != Some(&schema_ref("CapabilitiesResponse"))
703 {
704 failures.push("/v1/capabilities must return CapabilitiesResponse".to_owned());
705 }
706 if value
707 .pointer("/components/schemas/StatusResponse/properties/api_url")
708 .is_some()
709 {
710 failures.push(
711 "StatusResponse must not advertise api_url on the public status schema".to_owned(),
712 );
713 }
714 Ok(failures)
715}
716
717fn render_schema_docs(root: &Path) -> Result<String> {
718 let actions = action_entries(root)?;
719 let mut lines = vec![
720 "# MCP Schema Contract".to_owned(),
721 "".to_owned(),
722 "Generated from `crates/soma/domain/src/actions.rs` and checked against the schema, README, skill docs, help text, and scope routing.".to_owned(),
723 "".to_owned(),
724 "Run:".to_owned(),
725 "".to_owned(),
726 "```bash".to_owned(),
727 "cargo xtask check-schema-docs --write".to_owned(),
728 "cargo xtask check-schema-docs --check".to_owned(),
729 "```".to_owned(),
730 "".to_owned(),
731 "## Tool".to_owned(),
732 "".to_owned(),
733 "| Field | Value |".to_owned(),
734 "|---|---|".to_owned(),
735 "| Tool name | `soma` |".to_owned(),
736 "| Schema resource | `soma://schema/mcp-tool` |".to_owned(),
737 "| Dispatch parameter | `action` |".to_owned(),
738 "".to_owned(),
739 "## Actions".to_owned(),
740 "".to_owned(),
741 "| Action | Scope | Cost | Description |".to_owned(),
742 "|---|---|---|---|".to_owned(),
743 ];
744 for action in &actions {
745 lines.push(format!(
746 "| `{}` | {} | `{}` | {} |",
747 action.name, action.doc_scope, action.cost, action.description
748 ));
749 }
750 lines.extend(SCHEMA_DOC_TAIL.iter().map(|line| (*line).to_owned()));
751 Ok(lines.join("\n"))
752}
753
754const SCHEMA_DOC_TAIL: &[&str] = &[
755 "",
756 "## Drift Rules",
757 "",
758 "- `ACTION_SPECS` in `crates/soma/domain/src/actions.rs` is the canonical action and scope list.",
759 "- Action cost is planner metadata. Use `cheap` for first-pass reads, `moderate` for bounded workflow setup, `expensive` for broad scans or long-running work, and `write` for mutating operations.",
760 "- `crates/soma/mcp/src/schemas.rs` must derive its enum from `ACTION_SPECS`.",
761 "- The MCP tool schema must reject unknown top-level parameters except reserved `_response_*` continuation fields, and encode action-specific requirements that fit the single-tool dispatch model.",
762 "- `help` is intentionally public and must have no required scope.",
763 "- `crates/soma/mcp/src/tools.rs`, `README.md`, and `plugins/soma/skills/soma/SKILL.md` must mention every action.",
764 "- `crates/soma/mcp/src/rmcp_server.rs` owns stable resources and must keep `soma://schema/mcp-tool` wired to `tool_definitions()`.",
765 "- `crates/soma/mcp/src/prompts.rs` owns stable prompts and must keep `quick_start` covered by prompt tests.",
766 "",
767 "## Resources",
768 "",
769 "| URI | Source | Contract |",
770 "|---|---|---|",
771 "| `soma://schema/mcp-tool` | `crates/soma/mcp/src/rmcp_server.rs` | Returns `tool_definitions()` as `application/json`. |",
772 "",
773 "## Prompts",
774 "",
775 "| Prompt | Source | Contract |",
776 "|---|---|---|",
777 "| `quick_start` | `crates/soma/mcp/src/prompts.rs` | Guides a client to call `status` and `greet`. |",
778 "",
779 "## Input Validation",
780 "",
781 "- `action` is always required.",
782 "- `echo` conditionally requires non-empty `message`.",
783 "- `greet` accepts optional `name` and defaults to World.",
784 "- `elicit_name` and `scaffold_intent` collect their extra fields through MCP elicitation, not direct tool-call arguments.",
785 "- Unknown top-level parameters are rejected by the schema except reserved MCP adapter continuation fields.",
786 "",
787 "## Reserved Adapter Parameters",
788 "",
789 "Oversized MCP responses are returned as `kind=mcp_response_page` envelopes. Continuation calls reuse the same tool and original arguments, plus these reserved fields:",
790 "",
791 "| Parameter | Type | Purpose |",
792 "|---|---|---|",
793 "| `_response_cursor` | string | Cursor for cached serialized response data. Required with `_response_offset`. |",
794 "| `_response_offset` | integer | Byte offset into the cached serialized response. |",
795 "| `_response_page_bytes` | integer | Page size in bytes, from 1 to 16000. |",
796 "",
797 "The adapter strips these fields before dispatching to the service layer.",
798 "",
799];
800
801fn check_schema_mentions(root: &Path, actions: &[ActionEntry]) -> Result<Vec<String>> {
802 let mut failures = Vec::new();
803 for (label, path) in [
804 ("README.md", root.join("README.md")),
805 (
806 "plugins/soma/skills/soma/SKILL.md",
807 root.join("plugins/soma/skills/soma/SKILL.md"),
808 ),
809 ] {
810 let text = read(&path)?;
811 for action in actions {
812 if !text.contains(&action.name) {
813 failures.push(format!("{label} does not mention action `{}`", action.name));
814 }
815 }
816 }
817 let tools_text = read(root.join("crates/soma/mcp/src/tools.rs"))?;
818 if !tools_text.contains("ACTION_SPECS") || !tools_text.contains("build_help_text") {
819 failures.push(
820 "crates/soma/mcp/src/tools.rs HELP_TEXT must be derived from ACTION_SPECS".to_owned(),
821 );
822 }
823 Ok(failures)
824}
825
826fn check_schema_scope(root: &Path, actions: &[ActionEntry]) -> Result<Vec<String>> {
827 let mut failures = Vec::new();
828 let action_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
829 let scope_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
830 let cost_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
831 if scope_names != action_names {
832 failures.push("ACTION_SPECS action names and scope entries are out of sync".to_owned());
833 }
834 if cost_names != action_names {
835 failures.push("ACTION_SPECS action names and cost entries are out of sync".to_owned());
836 }
837 if actions
838 .iter()
839 .find(|action| action.name == "help")
840 .map(|action| action.scope.as_str())
841 != Some("public")
842 {
843 failures.push("help must be public".to_owned());
844 }
845 for action in actions.iter().filter(|action| action.name != "help") {
846 if action.scope == "public" {
847 failures.push(format!(
848 "action `{}` must declare a required scope",
849 action.name
850 ));
851 }
852 }
853 let schema_text = read(root.join("crates/soma/mcp/src/schemas.rs"))?;
854 if !schema_text.contains("tool_definitions_for_catalogs")
855 || !schema_text.contains("action_names(catalogs)")
856 {
857 failures.push(
858 "crates/soma/mcp/src/schemas.rs must derive action enum from provider catalogs"
859 .to_owned(),
860 );
861 }
862 if !schema_text.contains("\"additionalProperties\": false") {
863 failures.push(
864 "crates/soma/mcp/src/schemas.rs must reject unknown top-level properties".to_owned(),
865 );
866 }
867 if !schema_text.contains("required_param_conditionals(catalogs)")
868 || !schema_text.contains("\"then\": { \"required\": required }")
869 {
870 failures.push("crates/soma/mcp/src/schemas.rs must derive required action parameters from provider catalogs".to_owned());
871 }
872 let rmcp_server_text = read(root.join("crates/soma/mcp/src/rmcp_server.rs"))?;
873 if !rmcp_server_text.contains("soma://schema/mcp-tool")
874 || !rmcp_server_text.contains("tool_definitions_for_state")
875 {
876 failures.push("crates/soma/mcp/src/rmcp_server.rs must expose the schema resource from the state-backed tool definitions".to_owned());
877 }
878 let prompts_text = read(root.join("crates/soma/mcp/src/prompts.rs"))?;
879 if !prompts_text.contains("quick_start") {
880 failures.push("crates/soma/mcp/src/prompts.rs must expose quick_start prompt".to_owned());
881 }
882 Ok(failures)
883}
884
885fn validate_scaffold_schema(path: &Path) -> Result<()> {
886 let schema = load_json(path)?;
887 require(
888 schema.as_object().is_some(),
889 format!("{}: schema root must be an object", path.display()),
890 )?;
891 require(
892 schema.get("$schema").and_then(Value::as_str)
893 == Some("https://json-schema.org/draft/2020-12/schema"),
894 format!("{}: expected JSON Schema draft 2020-12", path.display()),
895 )?;
896 require(
897 schema
898 .pointer("/properties/kind/const")
899 .and_then(Value::as_str)
900 == Some("soma_scaffold_intent"),
901 format!("{}: kind const drifted", path.display()),
902 )?;
903 let expected_required = str_set(&[
904 "kind",
905 "schema_version",
906 "server_category",
907 "required_surfaces",
908 "project",
909 "upstream",
910 "runtime",
911 "mcp_primitives",
912 "deployment",
913 "plugins",
914 "publish_mcp",
915 "crawl_docs",
916 "handoff",
917 "policy",
918 ]);
919 let required = string_set(schema.get("required").unwrap_or(&Value::Null));
920 require(
921 required == expected_required,
922 format!(
923 "{}: root required fields drifted: {:?}",
924 path.display(),
925 sorted_set(&required)
926 ),
927 )?;
928 let properties = schema
929 .get("properties")
930 .and_then(Value::as_object)
931 .map(|map| map.keys().map(String::as_str).collect::<BTreeSet<_>>())
932 .unwrap_or_default();
933 require(
934 expected_required.iter().all(|key| properties.contains(key)),
935 format!(
936 "{}: root properties missing required fields",
937 path.display()
938 ),
939 )?;
940 require(
941 !properties.contains("actions"),
942 format!("{}: legacy actions property must not exist", path.display()),
943 )?;
944 require(
945 !schema.to_string().contains("resource_groups"),
946 format!(
947 "{}: legacy resource_groups field must not exist",
948 path.display()
949 ),
950 )?;
951 let auth_enum = string_set(
952 schema
953 .pointer("/properties/upstream/properties/auth_kind/enum")
954 .unwrap_or(&Value::Null),
955 );
956 require(
957 auth_enum == str_set(AUTH_KINDS),
958 format!(
959 "{}: auth_kind enum mismatch: {:?}",
960 path.display(),
961 sorted_set(&auth_enum)
962 ),
963 )?;
964 Ok(())
965}
966
967fn validate_scaffold_payload(payload: &Value, source: &Path) -> Result<()> {
968 let Some(obj) = payload.as_object() else {
969 bail!("{}: root must be an object", source.display());
970 };
971 let root_keys = str_set(&[
972 "kind",
973 "schema_version",
974 "server_category",
975 "required_surfaces",
976 "project",
977 "upstream",
978 "runtime",
979 "mcp_primitives",
980 "deployment",
981 "plugins",
982 "publish_mcp",
983 "crawl_docs",
984 "handoff",
985 "policy",
986 ]);
987 require_keys(obj, source, "", &root_keys)?;
988 require_no_extra(obj, source, "", &root_keys)?;
989 require(
990 payload.get("kind").and_then(Value::as_str) == Some("soma_scaffold_intent"),
991 format!("{}: invalid kind", source.display()),
992 )?;
993 require(
994 payload.get("schema_version").and_then(Value::as_i64) == Some(1),
995 format!("{}: invalid schema_version", source.display()),
996 )?;
997
998 let category = required_str(payload, "server_category", source)?;
999 require(
1000 matches!(category, "upstream-client" | "application-platform"),
1001 format!("{}: invalid server_category", source.display()),
1002 )?;
1003 let surfaces = unique_list(
1004 payload.get("required_surfaces").unwrap_or(&Value::Null),
1005 format!("{}: required_surfaces", source.display()),
1006 Some(SURFACES),
1007 )?;
1008 if category == "upstream-client" {
1009 require(
1010 surfaces == ["mcp", "cli"],
1011 format!(
1012 "{}: upstream-client must use ['mcp', 'cli']",
1013 source.display()
1014 ),
1015 )?;
1016 } else {
1017 require(
1018 string_slice_set(&surfaces) == str_set(&["api", "cli", "mcp", "web"]),
1019 format!(
1020 "{}: application-platform must include api, cli, mcp, web",
1021 source.display()
1022 ),
1023 )?;
1024 }
1025
1026 validate_project(payload.get("project").unwrap_or(&Value::Null), source)?;
1027 validate_upstream(payload.get("upstream").unwrap_or(&Value::Null), source)?;
1028 validate_runtime(
1029 payload.get("runtime").unwrap_or(&Value::Null),
1030 source,
1031 category,
1032 )?;
1033 unique_list(
1034 payload.get("mcp_primitives").unwrap_or(&Value::Null),
1035 format!("{}: mcp_primitives", source.display()),
1036 Some(PRIMITIVES),
1037 )?;
1038 require(
1039 in_allowed(required_str(payload, "deployment", source)?, DEPLOYMENTS),
1040 format!("{}: invalid deployment", source.display()),
1041 )?;
1042 unique_list(
1043 payload.get("plugins").unwrap_or(&Value::Null),
1044 format!("{}: plugins", source.display()),
1045 Some(PLUGINS),
1046 )?;
1047 require(
1048 payload
1049 .get("publish_mcp")
1050 .and_then(Value::as_bool)
1051 .is_some(),
1052 format!("{}: publish_mcp must be boolean", source.display()),
1053 )?;
1054 validate_crawl(payload.get("crawl_docs").unwrap_or(&Value::Null), source)?;
1055 validate_handoff(payload.get("handoff").unwrap_or(&Value::Null), source)?;
1056 validate_policy(payload.get("policy").unwrap_or(&Value::Null), source)?;
1057 Ok(())
1058}
1059
1060fn validate_project(value: &Value, source: &Path) -> Result<()> {
1061 let obj = object(value, source, "project")?;
1062 let keys = str_set(&[
1063 "display_name",
1064 "crate_name",
1065 "binary_name",
1066 "service_name",
1067 "env_prefix",
1068 ]);
1069 require_keys(obj, source, "project", &keys)?;
1070 require_no_extra(obj, source, "project", &keys)?;
1071 require(
1072 !required_obj_str(obj, "display_name", source, "project")?.is_empty(),
1073 format!("{}: project.display_name required", source.display()),
1074 )?;
1075 require(
1076 is_crate_name(required_obj_str(obj, "crate_name", source, "project")?),
1077 format!("{}: invalid crate_name", source.display()),
1078 )?;
1079 require(
1080 is_crate_name(required_obj_str(obj, "binary_name", source, "project")?),
1081 format!("{}: invalid binary_name", source.display()),
1082 )?;
1083 require(
1084 is_ident(required_obj_str(obj, "service_name", source, "project")?),
1085 format!("{}: invalid service_name", source.display()),
1086 )?;
1087 require(
1088 is_env(required_obj_str(obj, "env_prefix", source, "project")?),
1089 format!("{}: invalid env_prefix", source.display()),
1090 )?;
1091 Ok(())
1092}
1093
1094fn validate_upstream(value: &Value, source: &Path) -> Result<()> {
1095 let obj = object(value, source, "upstream")?;
1096 let keys = str_set(&["base_url_env", "auth_kind"]);
1097 require_no_extra(obj, source, "upstream", &keys)?;
1098 require(
1099 is_api_url_env(required_obj_str(obj, "base_url_env", source, "upstream")?),
1100 format!("{}: invalid upstream.base_url_env", source.display()),
1101 )?;
1102 require(
1103 in_allowed(
1104 required_obj_str(obj, "auth_kind", source, "upstream")?,
1105 AUTH_KINDS,
1106 ),
1107 format!("{}: invalid auth_kind", source.display()),
1108 )?;
1109 Ok(())
1110}
1111
1112fn validate_runtime(value: &Value, source: &Path, category: &str) -> Result<()> {
1113 let obj = object(value, source, "runtime")?;
1114 let keys = str_set(&["host", "port", "binary_profile", "mcp_transport"]);
1115 require_no_extra(obj, source, "runtime", &keys)?;
1116 require(
1117 !required_obj_str(obj, "host", source, "runtime")?.is_empty(),
1118 format!("{}: runtime.host required", source.display()),
1119 )?;
1120 let port = obj.get("port").and_then(Value::as_i64).unwrap_or_default();
1121 require(
1122 (1..=65535).contains(&port),
1123 format!("{}: runtime.port out of range", source.display()),
1124 )?;
1125 let profile = required_obj_str(obj, "binary_profile", source, "runtime")?;
1126 require(
1127 in_allowed(profile, BINARY_PROFILES),
1128 format!("{}: invalid runtime.binary_profile", source.display()),
1129 )?;
1130 if category == "upstream-client" {
1131 require(
1132 profile == "local-adapter",
1133 format!(
1134 "{}: upstream-client must default to local-adapter binary profile",
1135 source.display()
1136 ),
1137 )?;
1138 } else {
1139 require(
1140 profile == "server-full",
1141 format!(
1142 "{}: application-platform must default to server-full binary profile",
1143 source.display()
1144 ),
1145 )?;
1146 }
1147 require(
1148 in_allowed(
1149 required_obj_str(obj, "mcp_transport", source, "runtime")?,
1150 TRANSPORTS,
1151 ),
1152 format!("{}: invalid runtime.mcp_transport", source.display()),
1153 )?;
1154 Ok(())
1155}
1156
1157fn validate_crawl(value: &Value, source: &Path) -> Result<()> {
1158 let obj = object(value, source, "crawl_docs")?;
1159 let keys = str_set(&["urls", "repos", "search_topics"]);
1160 require_no_extra(obj, source, "crawl_docs", &keys)?;
1161 for key in ["urls", "repos", "search_topics"] {
1162 let values = unique_list(
1163 obj.get(key).unwrap_or(&Value::Null),
1164 format!("{}: crawl_docs.{key}", source.display()),
1165 None,
1166 )?;
1167 require(
1168 values.iter().all(|item| !item.is_empty()),
1169 format!(
1170 "{}: crawl_docs.{key} entries must be non-empty strings",
1171 source.display()
1172 ),
1173 )?;
1174 if matches!(key, "urls" | "repos") {
1175 require(
1176 values.iter().all(|item| is_uri(item)),
1177 format!(
1178 "{}: crawl_docs.{key} entries must be URIs",
1179 source.display()
1180 ),
1181 )?;
1182 }
1183 }
1184 Ok(())
1185}
1186
1187fn validate_handoff(value: &Value, source: &Path) -> Result<()> {
1188 let obj = object(value, source, "handoff")?;
1189 let keys = str_set(&["recommended_skill", "instructions"]);
1190 require_keys(obj, source, "handoff", &keys)?;
1191 require_no_extra(obj, source, "handoff", &keys)?;
1192 require(
1193 required_obj_str(obj, "recommended_skill", source, "handoff")? == "scaffold-project",
1194 format!(
1195 "{}: handoff.recommended_skill must be scaffold-project",
1196 source.display()
1197 ),
1198 )?;
1199 require(
1200 required_obj_str(obj, "instructions", source, "handoff")?
1201 .to_lowercase()
1202 .contains("approve"),
1203 format!(
1204 "{}: handoff instructions must mention approval",
1205 source.display()
1206 ),
1207 )?;
1208 Ok(())
1209}
1210
1211fn validate_policy(value: &Value, source: &Path) -> Result<()> {
1212 let obj = object(value, source, "policy")?;
1213 let keys = str_set(&[
1214 "business_action_minimum_surfaces",
1215 "upstream_client_surfaces",
1216 "application_platform_surfaces",
1217 "binary_profiles",
1218 ]);
1219 require_keys(obj, source, "policy", &keys)?;
1220 require_no_extra(obj, source, "policy", &keys)?;
1221 require(
1222 string_vec(
1223 obj.get("business_action_minimum_surfaces")
1224 .unwrap_or(&Value::Null),
1225 ) == ["mcp", "cli"],
1226 format!(
1227 "{}: business action minimum must be ['mcp', 'cli']",
1228 source.display()
1229 ),
1230 )?;
1231 require(
1232 string_vec(obj.get("upstream_client_surfaces").unwrap_or(&Value::Null)) == ["mcp", "cli"],
1233 format!("{}: upstream policy mismatch", source.display()),
1234 )?;
1235 require(
1236 string_slice_set(&string_vec(
1237 obj.get("application_platform_surfaces")
1238 .unwrap_or(&Value::Null),
1239 )) == str_set(&["api", "cli", "mcp", "web"]),
1240 format!("{}: application policy mismatch", source.display()),
1241 )?;
1242 let profiles = object(
1243 obj.get("binary_profiles").unwrap_or(&Value::Null),
1244 source,
1245 "policy.binary_profiles",
1246 )?;
1247 let profile_keys = str_set(&[
1248 "upstream_client_default",
1249 "application_platform_default",
1250 "gateway_shared_default",
1251 ]);
1252 require_no_extra(profiles, source, "policy.binary_profiles", &profile_keys)?;
1253 require(
1254 required_obj_str(
1255 profiles,
1256 "upstream_client_default",
1257 source,
1258 "policy.binary_profiles",
1259 )? == "local-adapter",
1260 format!(
1261 "{}: upstream binary profile policy mismatch",
1262 source.display()
1263 ),
1264 )?;
1265 require(
1266 required_obj_str(
1267 profiles,
1268 "application_platform_default",
1269 source,
1270 "policy.binary_profiles",
1271 )? == "server-full",
1272 format!(
1273 "{}: application binary profile policy mismatch",
1274 source.display()
1275 ),
1276 )?;
1277 require(
1278 required_obj_str(
1279 profiles,
1280 "gateway_shared_default",
1281 source,
1282 "policy.binary_profiles",
1283 )? == "server-full",
1284 format!(
1285 "{}: gateway binary profile policy mismatch",
1286 source.display()
1287 ),
1288 )?;
1289 Ok(())
1290}
1291
1292fn action_entries(root: &Path) -> Result<Vec<ActionEntry>> {
1293 let text = read(root.join("crates/soma/domain/src/actions.rs"))?;
1294 Ok(parse_action_entries(&text))
1295}
1296
1297fn extract_actions(root: &Path) -> Result<Vec<ActionEntry>> {
1298 action_entries(root)
1299}
1300
1301fn parse_action_entries(text: &str) -> Vec<ActionEntry> {
1302 action_blocks(text)
1303 .into_iter()
1304 .filter_map(|entry| {
1305 let name = field_string(entry, "name")?;
1306 let description = field_string(entry, "description")?;
1307 let scope_expr = field_expr(entry, "required_scope")?;
1308 let transport = enum_variant(entry, "transport", "ActionTransport")?;
1309 let rest_method = option_string_expr(field_expr(entry, "rest_method")?.as_str())?;
1310 let rest_path = option_string_expr(field_expr(entry, "rest_path")?.as_str())?;
1311 let cost = enum_variant(entry, "cost", "ActionCost")?.to_lowercase();
1312 let (scope, doc_scope) = match scope_expr.trim() {
1313 "None" => ("public".to_owned(), "public".to_owned()),
1314 "Some(READ_SCOPE)" => ("soma:read".to_owned(), "`soma:read`".to_owned()),
1315 "Some(WRITE_SCOPE)" => ("soma:write".to_owned(), "`soma:write`".to_owned()),
1316 _ => ("soma:__deny__".to_owned(), "`soma:__deny__`".to_owned()),
1317 };
1318 Some(ActionEntry {
1319 name,
1320 description,
1321 scope,
1322 doc_scope,
1323 transport,
1324 rest_method,
1325 rest_path,
1326 cost,
1327 })
1328 })
1329 .collect()
1330}
1331
1332fn action_spec_count(root: &Path) -> Result<usize> {
1333 let text = read(root.join("crates/soma/domain/src/actions.rs"))?;
1334 Ok(action_blocks(&text)
1335 .into_iter()
1336 .filter(|block| block.trim_start().starts_with("name:"))
1337 .count())
1338}
1339
1340fn action_blocks(text: &str) -> Vec<&str> {
1341 let mut blocks = Vec::new();
1342 let mut rest = text;
1343 while let Some(start) = rest.find("ActionSpec") {
1344 rest = &rest[start + "ActionSpec".len()..];
1345 let Some(open) = rest.find('{') else {
1346 break;
1347 };
1348 rest = &rest[open + 1..];
1349 let Some(close) = rest.find('}') else {
1350 break;
1351 };
1352 blocks.push(&rest[..close]);
1353 rest = &rest[close + 1..];
1354 }
1355 blocks
1356}
1357
1358fn field_string(entry: &str, name: &str) -> Option<String> {
1359 let marker = format!("{name}:");
1360 let start = entry.find(&marker)? + marker.len();
1361 let after = entry[start..].trim_start();
1362 let after = after.strip_prefix('"')?;
1363 let end = after.find('"')?;
1364 Some(after[..end].to_owned())
1365}
1366
1367fn field_expr(entry: &str, name: &str) -> Option<String> {
1368 let marker = format!("{name}:");
1369 let start = entry.find(&marker)? + marker.len();
1370 let after = entry[start..].trim_start();
1371 let end = after.find([',', '\n']).unwrap_or(after.len());
1372 Some(after[..end].trim().to_owned())
1373}
1374
1375fn enum_variant(entry: &str, field: &str, enum_name: &str) -> Option<String> {
1376 let expr = field_expr(entry, field)?;
1377 let marker = format!("{enum_name}::");
1378 let variant = expr.strip_prefix(&marker)?;
1379 Some(variant.trim().to_owned())
1380}
1381
1382fn package_version(root: &Path) -> Result<String> {
1383 for manifest in [root.join("apps/soma/Cargo.toml"), root.join("Cargo.toml")] {
1384 if !manifest.exists() {
1385 continue;
1386 }
1387 let text = read(&manifest)?;
1388 for line in text.lines() {
1389 let line = line.trim();
1390 if let Some(value) = line
1391 .strip_prefix("version")
1392 .and_then(|line| line.trim_start().strip_prefix('='))
1393 {
1394 return Ok(value.trim().trim_matches('"').to_owned());
1395 }
1396 }
1397 }
1398 bail!("could not find package version in Cargo.toml")
1399}
1400
1401fn default_mcp_port(root: &Path) -> Result<u16> {
1402 let text = read(root.join("crates/soma/config/src/config.rs"))?;
1403 let Some(start) = text.find("fn default_mcp_port()") else {
1404 bail!("could not find default_mcp_port in config.rs");
1405 };
1406 let after = &text[start..];
1407 let Some(open) = after.find('{') else {
1408 bail!("could not parse default_mcp_port in config.rs");
1409 };
1410 let Some(close) = after[open + 1..].find('}') else {
1411 bail!("could not parse default_mcp_port in config.rs");
1412 };
1413 let value = after[open + 1..open + 1 + close].trim();
1414 value
1415 .parse::<u16>()
1416 .with_context(|| format!("could not parse default_mcp_port value {value:?}"))
1417}
1418
1419fn option_string_expr(value: &str) -> Option<Option<String>> {
1420 let value = value.trim();
1421 if value == "None" {
1422 return Some(None);
1423 }
1424 let inner = value.strip_prefix("Some(\"")?.strip_suffix("\")")?;
1425 Some(Some(inner.to_owned()))
1426}
1427
1428fn schema_ref(name: &str) -> Value {
1429 json!({"$ref": format!("#/components/schemas/{name}")})
1430}
1431
1432fn param_example(action: &str) -> Value {
1433 match action {
1434 "greet" => json!({"name":"Alice"}),
1435 "echo" => json!({"message":"Hello!"}),
1436 _ => json!({}),
1437 }
1438}
1439
1440fn rest_schemas(action: &str) -> Option<(Option<&'static str>, &'static str)> {
1441 REST_SCHEMAS
1442 .iter()
1443 .find(|(name, _, _)| *name == action)
1444 .map(|(_, request, response)| (*request, *response))
1445}
1446
1447fn title_no_underscore(value: &str) -> String {
1448 value
1449 .split('_')
1450 .filter(|part| !part.is_empty())
1451 .map(|part| {
1452 let mut chars = part.chars();
1453 match chars.next() {
1454 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
1455 None => String::new(),
1456 }
1457 })
1458 .collect()
1459}
1460
1461fn required_openapi_paths(root: &Path) -> Result<Vec<String>> {
1462 let mut paths = vec![
1463 "/health".to_owned(),
1464 "/openapi.json".to_owned(),
1465 "/status".to_owned(),
1466 "/v1/capabilities".to_owned(),
1467 "/v1/providers".to_owned(),
1468 "/v1/tools/{action}".to_owned(),
1469 ];
1470 paths.extend(
1471 action_entries(root)?
1472 .into_iter()
1473 .filter(|entry| entry.transport == "Any")
1474 .filter_map(|entry| entry.rest_path),
1475 );
1476 Ok(paths)
1477}
1478
1479fn escape_pointer(value: &str) -> String {
1480 value.replace('~', "~0").replace('/', "~1")
1481}
1482
1483fn load_json(path: &Path) -> Result<Value> {
1484 let text = read(path)?;
1485 serde_json::from_str(&text).with_context(|| format!("{}: invalid JSON", path.display()))
1486}
1487
1488fn read(path: impl AsRef<Path>) -> Result<String> {
1489 let path = path.as_ref();
1490 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
1491}
1492
1493fn canonical_json(value: &Value) -> Result<String> {
1494 Ok(format!("{}\n", serde_json::to_string_pretty(value)?))
1495}
1496
1497fn current_dir() -> Result<PathBuf> {
1498 std::env::current_dir().context("failed to read current directory")
1499}
1500
1501fn relative_display(root: &Path, path: &Path) -> String {
1502 path.strip_prefix(root)
1503 .unwrap_or(path)
1504 .to_string_lossy()
1505 .replace('\\', "/")
1506}
1507
1508fn finish_failures(failures: Vec<String>) -> Result<()> {
1509 if failures.is_empty() {
1510 return Ok(());
1511 }
1512 for failure in &failures {
1513 eprintln!("FAIL: {failure}");
1514 }
1515 bail!("check failed")
1516}
1517
1518fn require(condition: bool, message: String) -> Result<()> {
1519 if condition {
1520 Ok(())
1521 } else {
1522 bail!(message)
1523 }
1524}
1525
1526fn object<'a>(
1527 value: &'a Value,
1528 source: &Path,
1529 path: &str,
1530) -> Result<&'a serde_json::Map<String, Value>> {
1531 value
1532 .as_object()
1533 .with_context(|| format!("{}: {path} must be object", source.display()))
1534}
1535
1536fn require_keys(
1537 obj: &serde_json::Map<String, Value>,
1538 source: &Path,
1539 path: &str,
1540 keys: &BTreeSet<&str>,
1541) -> Result<()> {
1542 let actual: BTreeSet<&str> = obj.keys().map(String::as_str).collect();
1543 let missing: Vec<&str> = keys.difference(&actual).copied().collect();
1544 let label = if path.is_empty() {
1545 source.display().to_string()
1546 } else {
1547 format!("{}: {path}", source.display())
1548 };
1549 require(
1550 missing.is_empty(),
1551 format!("{label}: missing required keys: {missing:?}"),
1552 )
1553}
1554
1555fn require_no_extra(
1556 obj: &serde_json::Map<String, Value>,
1557 source: &Path,
1558 path: &str,
1559 keys: &BTreeSet<&str>,
1560) -> Result<()> {
1561 let actual: BTreeSet<&str> = obj.keys().map(String::as_str).collect();
1562 let extra: Vec<&str> = actual.difference(keys).copied().collect();
1563 let label = if path.is_empty() {
1564 source.display().to_string()
1565 } else {
1566 format!("{}: {path}", source.display())
1567 };
1568 require(
1569 extra.is_empty(),
1570 format!("{label}: unexpected keys: {extra:?}"),
1571 )
1572}
1573
1574fn required_str<'a>(payload: &'a Value, key: &str, source: &Path) -> Result<&'a str> {
1575 payload
1576 .get(key)
1577 .and_then(Value::as_str)
1578 .with_context(|| format!("{}: {key} required", source.display()))
1579}
1580
1581fn required_obj_str<'a>(
1582 obj: &'a serde_json::Map<String, Value>,
1583 key: &str,
1584 source: &Path,
1585 path: &str,
1586) -> Result<&'a str> {
1587 obj.get(key)
1588 .and_then(Value::as_str)
1589 .with_context(|| format!("{}: {path}.{key} required", source.display()))
1590}
1591
1592fn unique_list(value: &Value, path: String, allowed: Option<&[&str]>) -> Result<Vec<String>> {
1593 let Some(items) = value.as_array() else {
1594 bail!("{path}: expected list");
1595 };
1596 let values: Vec<String> = items
1597 .iter()
1598 .filter_map(Value::as_str)
1599 .map(str::to_owned)
1600 .collect();
1601 require(
1602 values.len() == items.len(),
1603 format!("{path}: entries must be strings"),
1604 )?;
1605 let set: BTreeSet<&str> = values.iter().map(String::as_str).collect();
1606 require(
1607 values.len() == set.len(),
1608 format!("{path}: duplicate values are not allowed"),
1609 )?;
1610 if let Some(allowed) = allowed {
1611 let invalid: Vec<&str> = set
1612 .iter()
1613 .copied()
1614 .filter(|item| !in_allowed(item, allowed))
1615 .collect();
1616 require(
1617 invalid.is_empty(),
1618 format!(
1619 "{path}: invalid values: {invalid:?}; allowed={:?}",
1620 sorted_slice(allowed)
1621 ),
1622 )?;
1623 }
1624 Ok(values)
1625}
1626
1627fn string_vec(value: &Value) -> Vec<String> {
1628 value
1629 .as_array()
1630 .map(|items| {
1631 items
1632 .iter()
1633 .filter_map(Value::as_str)
1634 .map(str::to_owned)
1635 .collect()
1636 })
1637 .unwrap_or_default()
1638}
1639
1640fn string_set(value: &Value) -> BTreeSet<&str> {
1641 value
1642 .as_array()
1643 .map(|items| items.iter().filter_map(Value::as_str).collect())
1644 .unwrap_or_default()
1645}
1646
1647fn str_set<'a>(items: &'a [&'a str]) -> BTreeSet<&'a str> {
1648 items.iter().copied().collect()
1649}
1650
1651fn string_slice_set(items: &[String]) -> BTreeSet<&str> {
1652 items.iter().map(String::as_str).collect()
1653}
1654
1655fn sorted_set(set: &BTreeSet<&str>) -> Vec<String> {
1656 set.iter().map(|item| (*item).to_owned()).collect()
1657}
1658
1659fn sorted_slice<'a>(items: &'a [&'a str]) -> Vec<&'a str> {
1660 let mut items = items.to_vec();
1661 items.sort_unstable();
1662 items
1663}
1664
1665fn in_allowed(value: &str, allowed: &[&str]) -> bool {
1666 allowed.contains(&value)
1667}
1668
1669fn is_uri(value: &str) -> bool {
1670 for scheme in ["http://", "https://", "ssh://", "git://"] {
1671 if let Some(rest) = value.strip_prefix(scheme) {
1672 return !rest.split('/').next().unwrap_or("").is_empty();
1673 }
1674 }
1675 false
1676}
1677
1678fn is_crate_name(value: &str) -> bool {
1679 let mut chars = value.chars();
1680 matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase())
1681 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1682}
1683
1684fn is_ident(value: &str) -> bool {
1685 let mut chars = value.chars();
1686 matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase())
1687 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
1688}
1689
1690fn is_env(value: &str) -> bool {
1691 let mut chars = value.chars();
1692 matches!(chars.next(), Some(ch) if ch.is_ascii_uppercase())
1693 && chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
1694}
1695
1696fn is_api_url_env(value: &str) -> bool {
1697 is_env(value) && value.ends_with("_API_URL")
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702 use super::{
1703 allowed_unicode, escape_char, is_api_url_env, is_crate_name, is_ident, is_uri,
1704 parse_action_entries, substitution, title_no_underscore,
1705 };
1706
1707 #[test]
1708 fn parses_action_specs_like_python_regex() {
1709 let text = r#"
1710 ActionSpec {
1711 name: "greet",
1712 description: "Return a greeting.",
1713 required_scope: Some(READ_SCOPE),
1714 transport: ActionTransport::Any,
1715 rest_method: Some("POST"),
1716 rest_path: Some("/v1/greet"),
1717 cost: ActionCost::Cheap,
1718 },
1719 ActionSpec {
1720 name: "elicit_name",
1721 description: "Ask for a name.",
1722 required_scope: Some(WRITE_SCOPE),
1723 transport: ActionTransport::McpOnly,
1724 rest_method: None,
1725 rest_path: None,
1726 cost: ActionCost::Moderate,
1727 },
1728 ActionSpec {
1729 name: "help",
1730 description: "Show help.",
1731 required_scope: None,
1732 transport: ActionTransport::Any,
1733 rest_method: Some("GET"),
1734 rest_path: Some("/v1/help"),
1735 cost: ActionCost::Cheap,
1736 },
1737 "#;
1738 let entries = parse_action_entries(text);
1739 assert_eq!(entries.len(), 3);
1740 assert_eq!(entries[0].name, "greet");
1741 assert_eq!(entries[0].description, "Return a greeting.");
1742 assert_eq!(entries[0].scope, "soma:read");
1743 assert_eq!(entries[0].rest_method.as_deref(), Some("POST"));
1744 assert_eq!(entries[0].rest_path.as_deref(), Some("/v1/greet"));
1745 assert_eq!(entries[1].transport, "McpOnly");
1746 assert_eq!(entries[1].rest_method, None);
1747 assert_eq!(entries[2].doc_scope, "public");
1748 }
1749
1750 #[test]
1751 fn ascii_allowlist_and_substitutions_match_script_policy() {
1752 assert!(allowed_unicode(0x2713));
1753 assert!(allowed_unicode(0x253C));
1754 assert!(allowed_unicode(0x25B2));
1755 assert!(allowed_unicode(0x25B6));
1756 assert!(allowed_unicode(0x25BC));
1757 assert!(!allowed_unicode(0x00E9));
1758 assert_eq!(substitution(0x2014), Some("-"));
1759 assert_eq!(substitution(0x2026), Some("..."));
1760 assert_eq!(escape_char('\\'), "\\\\");
1761 }
1762
1763 #[test]
1764 fn scaffold_regex_replacements_match_python_patterns() {
1765 assert!(is_crate_name("foo-bar1"));
1766 assert!(!is_crate_name("Foo"));
1767 assert!(is_ident("foo_bar1"));
1768 assert!(!is_ident("foo-bar"));
1769 assert!(is_api_url_env("FOO_API_URL"));
1770 assert!(!is_api_url_env("FOO_URL"));
1771 assert!(is_uri("https://example.com/docs"));
1772 assert!(!is_uri("mailto:test@example.com"));
1773 }
1774
1775 #[test]
1776 fn openapi_operation_id_title_matches_python_title_replace() {
1777 assert_eq!(title_no_underscore("scaffold_intent"), "ScaffoldIntent");
1778 assert_eq!(title_no_underscore("greet"), "Greet");
1779 }
1780}