Skip to content

feat(mcp): Extend trios-railway-mcp — IGLA canon + Tripwires #109-114 + Fleet tools + Tool aliases [AGENT TASK] #78

@gHashTag

Description

@gHashTag

OPERATOR ORDER — AGENT EXECUTES THIS IN FULL

Branch target: feat/mcp-tool-aliases (base: feat/railway-multi-client)
Tests target: 115 existing → 140 GREEN (+25)
No new crates. Extend crates/trios-railway-mcp/ only.


Phase A — cherry-pick workflow to main (30 sec)

File .github/workflows/build-mcp-image.yml already exists in feat/railway-multi-client.
Cherry-pick it to main so GHA triggers on merge:

git checkout main && git pull
git checkout origin/feat/railway-multi-client -- .github/workflows/build-mcp-image.yml
git commit -m "ci: register build-mcp-image workflow on main"
git push origin main

Phase B — feat/canon-shared-core (10 min)

Goal: Move bin/tri-gardener/src/canon.rscrates/trios-railway-core/src/canon.rs

git checkout feat/railway-multi-client
git checkout -b feat/canon-shared-core
git mv bin/tri-gardener/src/canon.rs crates/trios-railway-core/src/canon.rs

crates/trios-railway-core/src/lib.rs — add:

pub mod canon;
pub use canon::{IglAClassification, IglAError, IglAType};

bin/tri-gardener/src/canon.rs — replace with re-export:

// re-export from core for backward compat
pub use trios_railway_core::canon::*;

All 23 existing canon tests stay GREEN. Commit + push.


Phase C — feat/mcp-tool-aliases (MAIN WORK, 60-90 min)

Branch: feat/mcp-tool-aliases (base: feat/canon-shared-core)

C.1 — Add ALLOWED_PROJECT_IDS to multiclient.rs

File: crates/trios-railway-core/src/multiclient.rs

pub const ALLOWED_PROJECT_IDS: &[&str] = &[
    "da1fb0c7-199f-42b0-9f08-a84d122feb5b", // primary control plane
    "49a92e6d-1722-4f0b-8361-64b5b8577e37", // secondary control plane
    "e4fe33bb-3b09-4842-9782-7d2dea1abc9b", // Acc1 IGLA race
    "265301ce-0bf2-4187-a36f-348b0eb9942f", // Acc0 trios-trainer
    "39d833c1-4cb6-4af9-b61b-c204b6733a98", // Acc2 thriving-eagerness
];

Test: mutation_to_unknown_project_rejected

C.2 — Tripwires module

Create crates/trios-railway-mcp/src/tripwires/:

tripwires/
├── mod.rs           // registry, Result<(), McpError> guards
├── t109_no_direct_call.rs
├── t110_audit_append_only.rs
├── t111_tool_signature.rs
├── t112_account_scoped.rs
├── t113_dry_run_default.rs
└── t114_idempotency_key.rs

Each file pattern:

use rmcp::ErrorData as McpError;

pub fn check(/* minimal params */) -> Result<(), McpError> {
    // validation
    Ok(())
}

t109 — reject if no MCP session context (direct HTTP call without jsonrpc envelope)
t110 — reject UPDATE/DELETE SQL against audit ledger table
t111 — reject unknown tool names not in KNOWN_TOOL_NAMES set
t112 — reject destructive mutations (delete/deploy) without account field
t113dry_run defaults to true for destructive tools; require confirm=true to execute
t114 — idempotency key: same idempotency_key within 60s returns cached result

Integration in tools.rs — call tripwires at top of each handler:

async fn mcp_railway_delete(&self, ...) -> Result<...> {
    tripwires::t112_account_scoped::check(req.account.as_deref())?;
    tripwires::t113_dry_run_default::check_destructive(req.confirm, req.dry_run)?;
    // ... existing delete logic
}

Tests (7):

  • t109_direct_call_without_session_rejected
  • t110_update_query_on_audit_ledger_rejected
  • t111_unknown_tool_name_rejected + t111_legacy_railway_name_accepted
  • t112_delete_without_account_rejected
  • t113_delete_without_confirm_returns_dry_run + t113_confirm_true_executes
  • t114_same_idempotency_key_returns_same_result

C.3 — Tool aliases in tools.rs

Add new #[tool] entries — delegate to existing impls. Old names kept.

New alias Old name
mcp.railway.list railway_service_list
mcp.railway.deploy railway_service_deploy
mcp.railway.redeploy railway_service_redeploy
mcp.railway.delete railway_service_delete
mcp.experience.append railway_experience_append
mcp.audit.migrate railway_audit_migrate_sql
#[tool(name = "mcp.railway.list",
       description = "List Railway services. Multi-account: pass account=acc0..acc3.")]
async fn mcp_railway_list(&self,
    Parameters(req): Parameters<ListServicesRequest>) -> Result<CallToolResult, McpError> {
    self.railway_service_list(Parameters(req)).await
}
// ... repeat for all 6

Tests (6): tools_list_includes_mcp_dot_names — verify all 6 aliases present in tools/list response.

C.4 — IGLA Canon validation tool

File: crates/trios-railway-mcp/src/igla_canon.rs

use trios_railway_core::canon::IglAClassification;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ValidateRequest {
    pub name: String,
}

#[tool(name = "mcp.igla.validate",
       description = "Validate IGLA service name format: IGLA-<TYPE>-<FORMAT>-seed<N>")]
async fn mcp_igla_validate(&self,
    Parameters(req): Parameters<ValidateRequest>) -> Result<CallToolResult, McpError> {
    match IglAClassification::parse(&req.name) {
        Ok(class) => Ok(CallToolResult::success(vec![
            Content::text(serde_json::to_string(&class).unwrap())
        ])),
        Err(e) => Err(McpError::invalid_params(e.to_string(), None)),
    }
}

C.5 — Fleet tools

Files:

crates/trios-railway-mcp/src/fleet/
├── mod.rs
├── snapshot.rs   // mcp.fleet.snapshot — read-only, all 4 accounts
└── cleanup.rs    // mcp.fleet.cleanup — dry_run=true default, keep_pattern required

snapshot — fans out railway_service_list across all mc.registered() accounts, returns unified JSON.

cleanup contract:

#[derive(Debug, Deserialize, JsonSchema)]
pub struct FleetCleanupRequest {
    pub project_id: String,           // must be in ALLOWED_PROJECT_IDS
    pub keep_pattern: String,         // regex — services NOT matching are candidates
    pub account: String,              // required
    pub dry_run: Option<bool>,        // default true
    pub confirm: Option<bool>,        // must be true to actually delete
}

Safety: dry_run defaults true. Without confirm=true → returns preview list only, 0 deletes.

Tests (3):

  • snapshot_aggregates_across_4_accounts
  • cleanup_dry_run_lists_targets
  • cleanup_without_keep_pattern_rejected

C.6 — Connection logging

File: crates/trios-railway-mcp/src/connections.rs

use std::collections::HashMap;
use std::sync::RwLock;
use chrono::{DateTime, Utc};

pub struct ConnectionStats {
    pub client_id: String,
    pub connected_at: DateTime<Utc>,
    pub tools_called: Vec<String>,
    pub account_calls: HashMap<String, usize>,
}

lazy_static::lazy_static! {
    static ref CONNECTIONS: RwLock<HashMap<String, ConnectionStats>> =
        RwLock::new(HashMap::new());
}

pub fn log_call(client_id: &str, tool: &str, account: Option<&str>) {
    // update in-memory stats
    // every 60s emit JSON to stdout: {ts, client, account_calls, tools}
}

Test (1): tool_call_increments_counter


Acceptance Criteria

  • cargo build -p trios-railway-mcp — GREEN
  • cargo test --workspace140/140 GREEN (115 + 25 new)
  • tools/list response contains all 6 mcp.* aliases AND all 6 old railway_* names
  • mcp.igla.validate with "IGLA-TRAIN_V2-FP32-seed42" → OK
  • mcp.fleet.snapshot → JSON with 4 account sections
  • mcp.fleet.cleanup without confirm=true → dry_run preview, 0 deletes
  • mcp.railway.delete without account field → T112 error
  • All tripwires T109-T114 have passing unit tests
  • ALLOWED_PROJECT_IDS enforced on mutation tools

File Map

New files:

  • crates/trios-railway-mcp/src/tripwires/mod.rs + 6 tripwire files
  • crates/trios-railway-mcp/src/igla_canon.rs
  • crates/trios-railway-mcp/src/fleet/mod.rs + snapshot.rs + cleanup.rs
  • crates/trios-railway-mcp/src/connections.rs

Modified:

  • crates/trios-railway-mcp/src/tools.rs — aliases + tripwire calls
  • crates/trios-railway-mcp/src/main.rs — mod declarations
  • crates/trios-railway-core/src/lib.rs — export canon
  • crates/trios-railway-core/src/multiclient.rs — ALLOWED_PROJECT_IDS
  • bin/tri-gardener/src/canon.rs — re-export from core

Moved:

  • bin/tri-gardener/src/canon.rscrates/trios-railway-core/src/canon.rs

Phase D — After merge (operator)

Railway service b84f7b81 (project e4fe33bb) → Variables:

RAILWAY_TOKEN_ACC0=<personal_token_acc0>
RAILWAY_PROJECT_ID_ACC0=265301ce-0bf2-4187-a36f-348b0eb9942f
RAILWAY_ENVIRONMENT_ID_ACC0=f3517e98-c11a-49d8-b5fd-4cbb82d04384
RAILWAY_TOKEN_KIND_ACC0=team

RAILWAY_TOKEN_ACC1=<personal_token_acc1>
RAILWAY_PROJECT_ID_ACC1=e4fe33bb-3b09-4842-9782-7d2dea1abc9b
RAILWAY_ENVIRONMENT_ID_ACC1=54e293b9-00a9-4102-814d-db151636d96e
RAILWAY_TOKEN_KIND_ACC1=team

RAILWAY_TOKEN_ACC2=<personal_token_acc2>
RAILWAY_PROJECT_ID_ACC2=39d833c1-4cb6-4af9-b61b-c204b6733a98
RAILWAY_ENVIRONMENT_ID_ACC2=bce42949-d4ab-43d9-89d1-a6fcc576f45a
RAILWAY_TOKEN_KIND_ACC2=team

RAILWAY_TOKEN_ACC3=<personal_token_acc3>
RAILWAY_PROJECT_ID_ACC3=<create project in Acc3 → get UUID>
RAILWAY_ENVIRONMENT_ID_ACC3=<get UUID>
RAILWAY_TOKEN_KIND_ACC3=team

NEON_DATABASE_URL=<same pool as igla-final-seed-42>
PORT=8080
RUST_LOG=info

Perplexity connector:

Settings → Connectors → + Custom
Type:  Streamable HTTP MCP
Name:  trios-mcp-gateway
URL:   https://trios-railway-production.up.railway.app/mcp
Auth:  None

Phase E — Verification (after connector active)

  1. mcp.fleet.snapshot → all 4 accounts visible
  2. mcp.audit.migrate → apply gardener_runs + bpb_samples DDL
  3. mcp.fleet.cleanup --keep-pattern='IGLA-TRAIN_V2' --dry_run=true --account=acc1 --project=e4fe33bb → preview cull list
  4. mcp.railway.deploy --account=acc1 --name=IGLA-TRAIN_V2-FP32-E0042-rng43
  5. Publish LIVE LEADERBOARD in trios#143

Closes: #61, trios-mcp#2
Stacks on: #77
Blocks: Gate-0 smoke race (#66), Seed Hunter live run (#67)

φ²+φ⁻²=3 · TRINITY · ONE GATEWAY · AGENT EXECUTE

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions