diff --git a/.trinity/seals/mcp_tool_mcp_tri_mcp.json b/.trinity/seals/mcp_tool_mcp_tri_mcp.json index 6bcddc7694..ec6e892f46 100644 --- a/.trinity/seals/mcp_tool_mcp_tri_mcp.json +++ b/.trinity/seals/mcp_tool_mcp_tri_mcp.json @@ -1,14 +1,14 @@ { - "built_by": "t27c-bootstrap@0.5.0+1120d30ae", - "gen_hash_c": "sha256:559f17c9e2942a4e732666d74fb1079d35d1ea8f2e1e8a967b453c076f3eb595", - "gen_hash_rust": "sha256:1c820f3d29f91cd8e570b0f90709bf2a4d56b47b505f94df0880949a74145c50", - "gen_hash_verilog": "sha256:2ec9763a4c749561783d3ebb34252dc1f4a884064987bbb8d7cbd0c555d64f4d", - "gen_hash_zig": "sha256:4d499c482afffae6f97ee309682df8a914f721b30fe5ca89b6af0b91d897adb5", + "built_by": "t27c-bootstrap@0.5.2+1ab3a30aa", + "gen_hash_c": "sha256:94f2b9d84e506db530f2b6cbc1fa5ae9ceeb953a9bdbd5fdcd5a8ec1698e26cf", + "gen_hash_rust": "sha256:0dfc81535eadd2f97bb23984253f6bc8e5c41d13b415d3d7f5dfe9c5d65ce290", + "gen_hash_verilog": "sha256:eaaa0d85d10e6a39a9fce9f2a1833f7d1b5d7f0fb86b84c5c834e546560a0b48", + "gen_hash_zig": "sha256:faaea991871b395879a7f56e114e93e1119820145500c2b47ee85e0d2507a02f", "module": "tool_mcp_tri_mcp", "ring": 12, - "sealed_at": "2026-10-07T23:11:20Z", - "sealed_by": "t27c-bootstrap@0.5.0", - "spec_hash": "sha256:2db3e41d25dd8eff540b15459ce20402bac3d4520731a356b20a6fa8a81b9f3d", + "sealed_at": "2026-10-09T10:15:58Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:a0ff5b0e9e6f1793032f14f7a5f4baf31ea8fb6df1179e7a8fffc75a6e0f9db2", "spec_path": "specs/tools/mcp/tri-mcp.t27", "tests": { "failed": 0, diff --git a/Cargo.lock b/Cargo.lock index ff02e9da60..04ceac5d3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2590,6 +2590,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "tri-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "uuid", +] + [[package]] name = "trios-bridge" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a3970a77ea..180b184bbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["bootstrap", "cli/tri", "cli/trios-bridge", "cli/flash-spi", "cli/dlc10", "cli/t27b"] +members = ["bootstrap", "cli/tri", "cli/tri-mcp", "cli/trios-bridge", "cli/flash-spi", "cli/dlc10", "cli/t27b"] # `ffi` was in NEITHER members nor exclude, so `cargo publish` in it failed on # manifest load -- "current package believes it's in a workspace when it's not" # -- before it could reach a registry. That is why the crates.io leg of the diff --git a/cli/tri-mcp/src/main.rs b/cli/tri-mcp/src/main.rs index e0f85d11fa..9951b44ebf 100644 --- a/cli/tri-mcp/src/main.rs +++ b/cli/tri-mcp/src/main.rs @@ -10,6 +10,9 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use uuid::Uuid; +#[path = "../../../gen/rust/tri/test/report.rs"] #[allow(dead_code, unused_parens)] +mod report; // t27c gen-rust of specs/tri/test/report.t27 (#7369): the same reading of `t27c test-report` that `tri test` uses + #[derive(Debug, Clone, Serialize, Deserialize)] struct ActiveSkill { skill_id: String, @@ -226,14 +229,15 @@ fn build_tools_list() -> Vec { }), serde_json::json!({ "name": "tri_test", - "description": "Runs tests for a .t27 spec file using t27c.", + "description": "Runs a .t27 spec's tests with `t27c test-report`; isError unless every test passed and at least one ran (rules: specs/tri/test/report.t27).", "inputSchema": { "type": "object", "properties": { "spec_path": { "type": "string", "description": "Path to the .t27 spec file" - } + }, + "t27c": {"type": "string", "description": "The t27c binary; default target/release/t27c, then target/debug/t27c, then t27c on PATH"} }, "required": ["spec_path"] } @@ -722,58 +726,28 @@ fn cmd_gen(root: &Path, args: &Value) -> Result { } } +/// `t27c test` only lists the tests (#8140); they run in `t27c test-report`, and the verdict is report.t27's, as in `tri test`. fn cmd_test(root: &Path, args: &Value) -> Result { - let spec_path = args - .get("spec_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("missing 'spec_path' parameter"))?; - - let spec = Path::new(spec_path); - if !spec.exists() { - return Ok(tool_error_text(&format!( - "Spec file not found: {}", - spec_path - ))); - } - - let output = Command::new("t27c") - .args(["test", spec_path]) - .current_dir(root) - .output(); - - match output { - Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - let success = out.status.success(); - - append_akashic( - root, - &make_akashic_event( - "tri.test", - if success { "success" } else { "failure" }, - serde_json::json!({ - "spec_path": spec_path, - "exit_code": out.status.code() - }), - ), - )?; - - let result = serde_json::json!({ - "success": success, - "exit_code": out.status.code(), - "stdout": stdout, - "stderr": stderr, - "spec_path": spec_path - }); - - Ok(tool_result_text(&serde_json::to_string_pretty(&result)?)) - } - Err(e) => { - let msg = format!("Failed to execute t27c: {}. Is t27c on PATH?", e); - Ok(tool_error_text(&msg)) - } + let spec = args.get("spec_path").and_then(|v| v.as_str()).ok_or_else(|| anyhow::anyhow!("missing 'spec_path' parameter"))?; + if !root.join(spec).exists() { + return Ok(tool_error_text(&format!("Spec file not found: {}", spec))); } + let built = ["target/release/t27c", "target/debug/t27c"].iter().map(|p| root.join(p)).find(|p| p.exists()); + let t27c = args.get("t27c").and_then(|v| v.as_str()).map(PathBuf::from).or(built).unwrap_or_else(|| "t27c".into()); + let out = Command::new(&t27c).args(["test-report", spec]).current_dir(root).output().map_err(|e| anyhow::anyhow!("cannot start `{} test-report`: {}", t27c.display(), e))?; + let text: &'static str = String::from_utf8_lossy(&out.stdout).into_owned().leak(); // gen-rust's `string` is `&'static str` (#7449) + let (v, total) = (report::verdict(text, out.status.success()), |k| report::total(text, k)); + let names = |k| (0..report::count(text, k)).map(|n| &text[report::name_from(text, k, n)..report::name_to(text, k, n)]).collect::>(); + let outcome = if report::passes(v) { "success" } else { "failure" }; + append_akashic(root, &make_akashic_event("tri.test", outcome, serde_json::json!({"spec_path": spec, "verdict": v, "exit_code": out.status.code()})))?; + let result = serde_json::json!({ + "spec_path": spec, "passes": report::passes(v), "verdict": v, "why": report::why(v), + "tests": total(report::LINE_TESTS), "pass": total(report::LINE_PASS), "fail": total(report::LINE_FAIL), + "failed": names(report::LINE_FAIL_NAME), "blocked": names(report::LINE_BLOCKED), + "exit_code": out.status.code(), "report": text, "stderr": String::from_utf8_lossy(&out.stderr) + }); + let shown = serde_json::to_string_pretty(&result)?; + Ok(if report::passes(v) { tool_result_text(&shown) } else { tool_error_text(&shown) }) } fn cmd_verdict(root: &Path) -> Result { diff --git a/cli/tri-mcp/tests/tri_test.rs b/cli/tri-mcp/tests/tri_test.rs new file mode 100644 index 0000000000..ff8dc4395f --- /dev/null +++ b/cli/tri-mcp/tests/tri_test.rs @@ -0,0 +1,40 @@ +//! tri_test runs a spec's tests and reads the verdict through report.t27 (#8140): a green spec, a red copy +//! with one planted failure, and a stand-in t27c that only lists the tests, as `t27c test` did. +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use std::{io::Write, path::Path, process::{Command, Stdio}}; + +fn sha(p: &Path) -> Vec { Sha256::digest(std::fs::read(p).unwrap()).to_vec() } +/// One tools/call of tri_test on a server started in `root`: the result's text as JSON, and isError. +fn tri_test(root: &Path, t27c: &Path) -> (Value, bool) { + let mut s = Command::new(env!("CARGO_BIN_EXE_tri-mcp")).current_dir(root).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap(); + let args = json!({"spec_path": "gft_relu.t27", "t27c": t27c}); + writeln!(s.stdin.take().unwrap(), "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "tri_test", "arguments": args}})).unwrap(); + let r: Value = serde_json::from_slice(&s.wait_with_output().unwrap().stdout).unwrap(); + (serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(), r["result"]["isError"].as_bool().unwrap()) +} + +#[test] +fn tri_test_runs_the_tests_and_a_listing_is_not_a_pass() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let t27c = std::env::var_os("T27C").map(Into::into).or_else(|| ["release", "debug"].iter().map(|d| repo.join("target").join(d).join("t27c")).find(|p| p.exists())); + let t27c: std::path::PathBuf = t27c.expect("no t27c: run `cargo build -p t27c` or set T27C"); + let (spec, root) = (repo.join("specs/ternary/gft_relu.t27"), std::env::temp_dir().join(format!("tri-mcp-test-{}", std::process::id()))); + let (before, copy) = (sha(&spec), root.join("gft_relu.t27")); // a temp root, so the tracked akashic log stays as it is + std::fs::create_dir_all(root.join(".trinity")).unwrap(); + std::fs::copy(&spec, ©).unwrap(); + let (green, err) = tri_test(&root, &t27c); + assert!(!err && green["passes"] == true && green["tests"] == 4 && green["pass"] == 4 && green["fail"] == 0, "{green}"); + let src = std::fs::read_to_string(©).unwrap(); + std::fs::write(©, src.replacen("{ return 0; } // negative", "{ return x; } // negative", 1)).unwrap(); // #7400's plant + let (red, err) = tri_test(&root, &t27c); + assert!(err && red["passes"] == false && red["tests"] == 4 && red["pass"] == 3 && red["fail"] == 1 && red["failed"] == json!(["negz"]), "{red}"); + std::fs::write(©, &src).unwrap(); + assert_eq!(sha(©), before, "the copy is restored byte for byte"); + let stub = |name: &str, body: &str| { let p = root.join(name); assert!(Command::new("sh").args(["-c", "printf '%s' \"$2\" > \"$1\" && chmod +x \"$1\"", "sh"]).arg(&p).arg(body).status().unwrap().success()); p }; + let (listed, err) = tri_test(&root, &stub("lists-only", "#!/bin/sh\necho listed 4 tests\n")); // negative control: what `t27c test` gives + assert!(err && listed["passes"] == false && listed["verdict"] == 4 && listed["tests"] == 0, "{listed}"); + let (torn, err) = tri_test(&root, &stub("green-exit-1", "#!/bin/sh\nprintf '\\n tests 4\\n pass 4\\n FAIL 0\\n'\nexit 1\n")); + assert!(err && torn["passes"] == false && torn["verdict"] == 7 && torn["tests"] == 4, "{torn}"); // a green report from a failed run + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(sha(&spec), before, "the spec in the tree is untouched"); +} diff --git a/docs/reports/orphan_modules.json b/docs/reports/orphan_modules.json index 149a5c3104..1af6615a00 100644 --- a/docs/reports/orphan_modules.json +++ b/docs/reports/orphan_modules.json @@ -6,6 +6,7 @@ "ceilings": { "bootstrap": 7, "cli/tri": 0, + "cli/tri-mcp": 0, "cli/trios-bridge": 0, "cli/flash-spi": 0, "cli/dlc10": 0, diff --git a/specs/tools/mcp/tri-mcp.t27 b/specs/tools/mcp/tri-mcp.t27 index e3a9352289..2fe32f4698 100644 --- a/specs/tools/mcp/tri-mcp.t27 +++ b/specs/tools/mcp/tri-mcp.t27 @@ -2,7 +2,9 @@ ; specs/tools/mcp/tri-mcp.t27 -- tool mcp/tri-mcp, MCP server "tri-mcp" (gHashTag/t27) ; Source of truth for the tool card on t27.ai (#/tools?tool=mcp/tri-mcp). ; Tool names, descriptions and input keys are read from the server code or manifest named in -; SOURCE (witness source-parse, gHashTag/t27 @ 3cb7a4352492); nothing is typed by hand. +; SOURCE (witness source-parse, gHashTag/t27 @ 3cb7a4352492; tri_test's description and its t27c input +; were read from the branch that makes it run the tests, gHashTag/t27#8140. Before it, tri_test ran +; `t27c test`, which only lists the tests, and reported success whatever they did); nothing is typed by hand. ; Layer 5 of the ladder Specs -> Skills -> Crons -> Agents -> Tools. ASCII only (L3). ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -27,8 +29,8 @@ pub const ABOUT : str = "Rust MCP server exposing the PHI LOOP tri commands as t pub const ABOUT_SOURCE : str = "derived from the tool descriptions in cli/tri-mcp/src/main.rs build_tools_list"; ; Tools as the server lists them (tools/list), their descriptions and inputSchema property keys. pub const TOOLS : [10]str = ["tri_status", "tri_skill_begin", "tri_skill_end", "tri_cell_checkpoint", "tri_cell_seal", "tri_gen", "tri_test", "tri_verdict", "tri_experience_save", "tri_health"]; -pub const TOOLS_ABOUT : [10]str = ["Returns PHI LOOP status including active skill, cell, issue binding, and uncommitted changes.", "Begins a new PHI LOOP skill session bound to an issue.", "Ends the active PHI LOOP skill session.", "Records a checkpoint step in the active cell.", "Seals the active cell by computing a SHA-256 hash of its checkpoint history.", "Generates backends from a .t27 spec file using t27c.", "Runs tests for a .t27 spec file using t27c.", "Checks for toxic regressions by analyzing swarm health metrics.", "Saves an experience episode to the akashic log.", "Returns queen and swarm health status from .trinity state."]; -pub const TOOLS_INPUTS : [10]str = ["", "issue,description", "", "step", "", "spec_path", "spec_path", "", "episode_type,summary,ring", ""]; +pub const TOOLS_ABOUT : [10]str = ["Returns PHI LOOP status including active skill, cell, issue binding, and uncommitted changes.", "Begins a new PHI LOOP skill session bound to an issue.", "Ends the active PHI LOOP skill session.", "Records a checkpoint step in the active cell.", "Seals the active cell by computing a SHA-256 hash of its checkpoint history.", "Generates backends from a .t27 spec file using t27c.", "Runs a .t27 spec's tests with `t27c test-report`; isError unless every test passed and at least one ran (rules: specs/tri/test/report.t27).", "Checks for toxic regressions by analyzing swarm health metrics.", "Saves an experience episode to the akashic log.", "Returns queen and swarm health status from .trinity state."]; +pub const TOOLS_INPUTS : [10]str = ["", "issue,description", "", "step", "", "spec_path", "spec_path,t27c", "", "episode_type,summary,ring", ""]; pub const RESOURCES : [0]str = []; pub const RESOURCES_ABOUT : [0]str = []; ; Why TOOLS may be empty or differ from other declarations (external package, dynamic list).