diff --git a/.trinity/seals/t27mcp_T27mcpService.json b/.trinity/seals/t27mcp_T27mcpService.json new file mode 100644 index 0000000000..32ecc8b134 --- /dev/null +++ b/.trinity/seals/t27mcp_T27mcpService.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:1cef360bbc9f2c02bb01db68061f22cfffa8d11a590020cf985226ebc4c49fa0", + "gen_hash_rust": "sha256:fbc4aeb5c58e6ff12047914adfa9e78afa20b3c0a929f147695fdf02eeff0206", + "gen_hash_verilog": "sha256:d93a159aedd2755e732a99b3bee762950a28841a7f929b5b850dac1f0dafc534", + "gen_hash_zig": "sha256:ec231f1bc026c9b8575d9c4c62c7ed2ae5d8e34c4e0490d5bf9da1d28c1fa8e1", + "module": "T27mcpService", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T14:34:31Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:3d7957837be8ac3de14b18ce1c0ecdde805f3f71d4d801b62d476b2e82f4a1ae", + "spec_path": "specs/t27mcp/service.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 9, + "total": 9, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/specs/t27mcp/service.t27 b/specs/t27mcp/service.t27 new file mode 100644 index 0000000000..5d0c73fcdb --- /dev/null +++ b/specs/t27mcp/service.t27 @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27mcp/service.t27 -- one MCP service: the owner's TRI CLAW and the t27 MCP server behind one +; endpoint. Which side owns a tool name, which tools of the t27 server are mounted into TRI CLAW, which +; retire, and who may see the mounted ones. See the epic #10066. +; -- +; Today there are two servers. TRI CLAW (gHashTag/999-multibots-telegraf, apps/vibee-editor/render, the +; vibee-render service on Railway) serves about 170 tools over JSON-RPC at POST /mcp: crm_*, tg_*, +; browser_*, mail_*, plan_*, hive_* and more, behind the owner's agent key, with its own chat agent in the +; Telegram bot and the mini app. The t27 MCP server (gHashTag/t27-mcp) runs on the owner's machine over +; stdio and serves the t27 tools, whose logic is t27 compiled to wasm and whose data is the published +; corpus at t27.ai, plus seven tg_* tools that only forward to TRI CLAW. So an agent needs two servers and +; two configurations, TRI CLAW's own chat agent cannot check or find a .t27 spec (the t27 rules reach its +; coder persona as prompt text only), and the seven forwarders are a second path to the same account. +; -- +; The one service is TRI CLAW's /mcp, with the t27 tools mounted into it: +; - A name belongs to one home, by prefix. The t27 module owns T27_PREFIXES (t27_ and t27f_); every other +; name, tg_ included, is TRI CLAW's. "t27ai" has no underscore after t27, so it is not a t27 name. +; - admit() decides each tool a side offers to the one registry. A t27 tool is mounted. A tool the t27 +; server offers under a TRI CLAW name is a forwarder: inside TRI CLAW it would call the service it is +; part of, so it is dropped, and the app's own tool of that name answers. A TRI CLAW tool under a t27 +; name is a collision and is refused: the t27 module's tool keeps the name. A name that MCP would not +; carry (NAME_MAX characters of a-z 0-9 _) is refused on either side. +; - The mounted tools read public data and change nothing: they never send, never write the app's state +; and never touch the owner's accounts. So they need no confirmation card, and they are shown to the +; owner's scope only (an agent key, an app session or the bot's signed data). An editorial key and a +; game token keep the fixed tool sets TRI CLAW gives them; this spec adds nothing to those sets. +; - The local t27 server stays, for a client with no key: it serves the t27 names only (local_serves()). +; Its tg_* forwarders retire, since the same tools answer at the one endpoint. +; - TRI CLAW's coder persona carries CODER_TOOLS, so the chat agent that writes .t27 checks a spec, finds +; one by intent, reads one and reuses an example before it writes a line. +; The endpoint, its key variable and the forwarders' address stay in telegram.t27 until the forwarders +; retire; this spec does not copy them. The per-tool schemas stay in tool_schema.t27. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module T27mcpService; + +/// Homes: where a tool's handler runs. +pub const H_T27 : u32 = 0; +pub const H_TRI_CLAW : u32 = 1; +pub const HOME_NAME : [2]str = ["t27", "TRI CLAW"]; + +/// The name prefixes the t27 module owns. Every other name is TRI CLAW's. +pub const T27_PREFIXES : [2]str = ["t27_", "t27f_"]; + +/// MCP carries a tool name of at most 64 characters; both sides use a-z 0-9 _ only. +pub const NAME_MAX : u32 = 64; + +/// admit() answers. +pub const A_MOUNT : u32 = 0; +pub const A_DROP_FORWARDER : u32 = 1; +pub const A_REFUSE_COLLISION : u32 = 2; +pub const A_REFUSE_NAME : u32 = 3; +pub const ADMIT_TEXT : [4]str = [ + "mounted", + "dropped: a forwarder to TRI CLAW, whose own tool of this name answers", + "refused: the name belongs to the t27 module", + "refused: a tool name is 1 to 64 of a-z 0-9 _" +]; + +/// Caller scopes at the one endpoint. +pub const S_OWNER : u32 = 0; +pub const S_EDITORIAL : u32 = 1; +pub const S_GAME : u32 = 2; +pub const S_NONE : u32 = 3; +pub const SCOPE_COUNT : u32 = 4; + +/// The t27 tools TRI CLAW's coder persona carries. +pub const CODER_TOOLS : [4]str = ["t27_check", "t27_find", "t27_spec", "t27_examples"]; + +/// The functions take `string`, so the C route (gen-c, then a wasm the t27 MCP server ships) lowers them as +/// NUL-terminated text; TRI CLAW calls the same wasm, so the rule has one home. +pub fn has_prefix(name: string, prefix: string) -> bool { + var nn : u32 = (name.len as u32); + var pn : u32 = (prefix.len as u32); + if (nn < pn) { return false; } + var i : u32 = 0; + while (i < pn) { + if (name[i] != prefix[i]) { return false; } + i += 1; + } + return true; +} + +/// A name MCP carries and both sides accept: 1 to NAME_MAX characters of a-z 0-9 _. +pub fn name_ok(name: string) -> bool { + var n : u32 = (name.len as u32); + if (n == 0) { return false; } + if (n > NAME_MAX) { return false; } + var i : u32 = 0; + while (i < n) { + let c = name[i]; + let lower : bool = c >= 'a' and c <= 'z'; + let digit : bool = c >= '0' and c <= '9'; + if ((lower or digit or c == '_') == false) { return false; } + i += 1; + } + return true; +} + +/// The home that owns a name. +pub fn home_of(name: string) -> u32 { + var i : u32 = 0; + while (i < 2) { + if (has_prefix(name, T27_PREFIXES[i])) { return H_T27; } + i += 1; + } + return H_TRI_CLAW; +} + +/// What the one registry does with a tool that side `offered_by` offers under `name`. +pub fn admit(name: string, offered_by: u32) -> u32 { + if (name_ok(name) == false) { return A_REFUSE_NAME; } + let owner : u32 = home_of(name); + if (offered_by == H_T27) { + if (owner == H_T27) { return A_MOUNT; } + return A_DROP_FORWARDER; + } + if (owner == H_T27) { return A_REFUSE_COLLISION; } + return A_MOUNT; +} + +/// Whether a caller of scope `scope` sees a mounted t27 tool. +pub fn t27_visible(scope: u32) -> bool { + return scope == S_OWNER; +} + +/// Whether the local t27 server, the keyless one, still serves `name`. +pub fn local_serves(name: string) -> bool { + if (name_ok(name) == false) { return false; } + return home_of(name) == H_T27; +} + +test "the tables are the size they say" { + assert HOME_NAME[H_TRI_CLAW] == "TRI CLAW"; + assert T27_PREFIXES[0] == "t27_"; + assert T27_PREFIXES[1] == "t27f_"; + assert ADMIT_TEXT[A_MOUNT] == "mounted"; + assert SCOPE_COUNT == 4; +} + +test "home_of: t27 names by prefix, everything else is TRI CLAW's" { + assert home_of("t27_check") == H_T27; + assert home_of("t27_paper_claims") == H_T27; + assert home_of("t27f_view") == H_T27; + assert home_of("tg_dialogs") == H_TRI_CLAW; + assert home_of("crm_leads") == H_TRI_CLAW; + assert home_of("whoami") == H_TRI_CLAW; +} + +test "admit: the t27 server's own tools are mounted, its tg_ forwarders are dropped" { + assert admit("t27_status", H_T27) == A_MOUNT; + assert admit("t27_find", H_T27) == A_MOUNT; + assert admit("t27f_graph", H_T27) == A_MOUNT; + assert admit("tg_unanswered", H_T27) == A_DROP_FORWARDER; + assert admit("tg_scheduled", H_T27) == A_DROP_FORWARDER; +} + +test "admit: TRI CLAW's tools are mounted as they are" { + assert admit("tg_send", H_TRI_CLAW) == A_MOUNT; + assert admit("browser_open", H_TRI_CLAW) == A_MOUNT; + assert admit("hdl_run", H_TRI_CLAW) == A_MOUNT; +} + +test "the mounted t27 tools are the owner's only, and the coder persona's set is all t27" { + assert t27_visible(S_OWNER); + var s : u32 = 1; + while (s < SCOPE_COUNT) { + assert t27_visible(s) == false; + s = s + 1; + } + var i : u32 = 0; + while (i < 4) { + assert admit(CODER_TOOLS[i], H_T27) == A_MOUNT; + i += 1; + } +} + +test "local_serves: the keyless local server keeps the t27 names and retires the forwarders" { + assert local_serves("t27_check"); + assert local_serves("t27f_outline"); + assert local_serves("tg_history") == false; + assert local_serves("tg_search") == false; +} + +test "negative control: t27ai and t2_ are not t27 names, a short name is not a prefix match" { + assert home_of("t27ai") == H_TRI_CLAW; + assert home_of("t2_check") == H_TRI_CLAW; + assert home_of("t27") == H_TRI_CLAW; + assert home_of("t27f") == H_TRI_CLAW; + assert has_prefix("t2", "t27_") == false; +} + +test "negative control: a TRI CLAW tool under a t27 name is refused" { + assert admit("t27_check", H_TRI_CLAW) == A_REFUSE_COLLISION; + assert admit("t27f_view", H_TRI_CLAW) == A_REFUSE_COLLISION; +} + +test "negative control: a name MCP would not carry is refused on either side" { + assert admit("", H_T27) == A_REFUSE_NAME; + assert admit("T27_check", H_T27) == A_REFUSE_NAME; + assert admit("tg send", H_TRI_CLAW) == A_REFUSE_NAME; + assert admit("tg-send", H_TRI_CLAW) == A_REFUSE_NAME; + assert name_ok("t27_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert name_ok("t27_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == false; + assert local_serves("") == false; +}