use std::path::PathBuf;
use tsafe_core::contracts::{self, AuthorityContract};
use tsafe_core::profile;
use crate::errors::{McpError, McpErrorKind};
#[derive(Debug, Clone, Default)]
pub struct SessionArgs {
pub profile: Option<String>,
pub allowed_keys: Vec<String>,
pub denied_keys: Vec<String>,
pub contract: Option<String>,
pub allow_reveal: bool,
pub audit_source: Option<String>,
pub require_agent: bool,
}
impl SessionArgs {
pub fn parse(argv: &[String]) -> Result<Self, McpError> {
let mut out = SessionArgs {
require_agent: env_truthy("TSAFE_MCP_REQUIRE_AGENT", true),
..SessionArgs::default()
};
if let Ok(v) = std::env::var("TSAFE_MCP_PROFILE") {
if !v.is_empty() {
out.profile = Some(v);
}
}
if let Ok(v) = std::env::var("TSAFE_MCP_ALLOWED_KEYS") {
out.allowed_keys = split_csv(&v);
}
if let Ok(v) = std::env::var("TSAFE_MCP_DENIED_KEYS") {
out.denied_keys = split_csv(&v);
}
if let Ok(v) = std::env::var("TSAFE_MCP_CONTRACT") {
if !v.is_empty() {
out.contract = Some(v);
}
}
if env_truthy("TSAFE_MCP_ALLOW_REVEAL", false) {
out.allow_reveal = true;
}
if let Ok(v) = std::env::var("TSAFE_MCP_AUDIT_SOURCE") {
if !v.is_empty() {
out.audit_source = Some(v);
}
}
let mut i = 0;
while i < argv.len() {
match argv[i].as_str() {
"--profile" => {
i += 1;
out.profile = Some(require_value(argv, i, "--profile")?);
}
"--allowed-keys" => {
i += 1;
out.allowed_keys = split_csv(&require_value(argv, i, "--allowed-keys")?);
}
"--denied-keys" => {
i += 1;
out.denied_keys = split_csv(&require_value(argv, i, "--denied-keys")?);
}
"--contract" => {
i += 1;
out.contract = Some(require_value(argv, i, "--contract")?);
}
"--allow-reveal" => out.allow_reveal = true,
"--audit-source" => {
i += 1;
out.audit_source = Some(require_value(argv, i, "--audit-source")?);
}
"--no-require-agent" => out.require_agent = false,
other => {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
format!("unknown serve flag: '{other}'"),
));
}
}
i += 1;
}
Ok(out)
}
}
fn require_value(argv: &[String], i: usize, flag: &str) -> Result<String, McpError> {
argv.get(i).cloned().ok_or_else(|| {
McpError::new(
McpErrorKind::InvalidRequest,
format!("{flag} requires a value"),
)
})
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn env_truthy(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
}
Err(_) => default,
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub profile: String,
pub allowed_globs: Vec<String>,
pub denied_globs: Vec<String>,
pub contract: Option<AuthorityContract>,
pub allow_reveal: bool,
pub audit_source: String,
pub pid: u32,
pub require_agent: bool,
pub vault_path: PathBuf,
}
impl Session {
pub fn from_cli_args(args: &SessionArgs) -> Result<Self, McpError> {
let profile = args.profile.clone().unwrap_or_default();
let profile = profile.trim().to_string();
if profile.is_empty() {
return Err(McpError::new(
McpErrorKind::ProfileNotFound,
"--profile <name> is required",
));
}
let vault_path = profile::vault_path(&profile);
if !vault_path.exists() {
return Err(McpError::new(
McpErrorKind::ProfileNotFound,
format!("Profile '{profile}' not found at {}", vault_path.display()),
));
}
let contract = if let Some(name) = args.contract.as_deref() {
Some(load_contract(&profile, name)?)
} else {
None
};
let contract_supplies_scope = contract
.as_ref()
.map(|c| !c.allowed_secrets.is_empty())
.unwrap_or(false);
if args.allowed_keys.is_empty() && !contract_supplies_scope {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
"no scope: --allowed-keys or --contract required",
));
}
let pid = std::process::id();
let audit_source = args
.audit_source
.clone()
.unwrap_or_else(|| format!("mcp:unknown:{pid}"));
Ok(Session {
profile,
allowed_globs: args.allowed_keys.clone(),
denied_globs: args.denied_keys.clone(),
contract,
allow_reveal: args.allow_reveal,
audit_source,
pid,
require_agent: args.require_agent,
vault_path,
})
}
pub fn is_in_scope(&self, key: &str) -> bool {
let allowed_by_globs = if self.allowed_globs.is_empty() {
self.contract.is_some()
} else {
self.allowed_globs.iter().any(|pat| glob_match(pat, key))
};
if !allowed_by_globs {
return false;
}
if self.denied_globs.iter().any(|pat| glob_match(pat, key)) {
return false;
}
if let Some(contract) = &self.contract {
if !contract.allowed_secrets.is_empty()
&& !contract.allowed_secrets.iter().any(|s| s == key)
{
return false;
}
}
true
}
}
fn load_contract(_profile: &str, name: &str) -> Result<AuthorityContract, McpError> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let manifest = contracts::find_contracts_manifest(&cwd).ok_or_else(|| {
McpError::new(
McpErrorKind::InvalidRequest,
format!(
"contract '{name}' requested but no contracts manifest found from cwd {}",
cwd.display()
),
)
})?;
contracts::load_contract(&manifest, name).map_err(|e| {
McpError::new(
McpErrorKind::InvalidRequest,
format!("contract '{name}' could not be loaded: {e}"),
)
})
}
pub(crate) fn glob_match(pattern: &str, candidate: &str) -> bool {
glob_match_impl(pattern.as_bytes(), candidate.as_bytes())
}
fn glob_match_impl(pat: &[u8], s: &[u8]) -> bool {
let (mut pi, mut si) = (0usize, 0usize);
let (mut star_pi, mut star_si) = (None, 0usize);
while si < s.len() {
if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < pat.len() && pat[pi] == b'*' {
star_pi = Some(pi);
star_si = si;
pi += 1;
} else if let Some(sp) = star_pi {
pi = sp + 1;
star_si += 1;
si = star_si;
} else {
return false;
}
}
while pi < pat.len() && pat[pi] == b'*' {
pi += 1;
}
pi == pat.len()
}
pub(crate) fn looks_like_glob(s: &str) -> bool {
s.chars().any(|c| matches!(c, '*' | '?' | '[' | ']'))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_with_profile_and_keys(profile: &str, keys: &[&str]) -> SessionArgs {
SessionArgs {
profile: Some(profile.to_string()),
allowed_keys: keys.iter().map(|s| s.to_string()).collect(),
require_agent: false,
..SessionArgs::default()
}
}
#[test]
fn parse_recognises_flags() {
let argv = vec![
"--profile".to_string(),
"demo".to_string(),
"--allowed-keys".to_string(),
"demo/*,foo/bar".to_string(),
"--allow-reveal".to_string(),
"--audit-source".to_string(),
"mcp:claude:1234".to_string(),
];
let parsed = SessionArgs::parse(&argv).unwrap();
assert_eq!(parsed.profile.as_deref(), Some("demo"));
assert_eq!(parsed.allowed_keys, vec!["demo/*", "foo/bar"]);
assert!(parsed.allow_reveal);
assert_eq!(parsed.audit_source.as_deref(), Some("mcp:claude:1234"));
}
#[test]
fn from_cli_args_rejects_missing_profile() {
let args = SessionArgs::default();
let err = Session::from_cli_args(&args).unwrap_err();
assert_eq!(err.kind, McpErrorKind::ProfileNotFound);
}
#[test]
fn from_cli_args_rejects_blank_scope() {
let args = SessionArgs {
profile: Some("test".to_string()),
allowed_keys: vec![],
require_agent: false,
..SessionArgs::default()
};
let err = Session::from_cli_args(&args).unwrap_err();
assert!(matches!(
err.kind,
McpErrorKind::ProfileNotFound | McpErrorKind::InvalidRequest
));
}
#[test]
fn glob_match_basic_cases() {
assert!(glob_match("demo/*", "demo/foo"));
assert!(glob_match("demo/*", "demo/sub/bar"));
assert!(!glob_match("demo/*", "other/foo"));
assert!(glob_match("*", "anything"));
assert!(glob_match("?bc", "abc"));
assert!(!glob_match("?bc", "abcd"));
assert!(glob_match("DB_*", "DB_HOST"));
assert!(!glob_match("DB_*", "API_KEY"));
}
#[test]
fn is_in_scope_intersects_allow_and_deny() {
let s = Session {
profile: "demo".into(),
allowed_globs: vec!["demo/*".into()],
denied_globs: vec!["demo/secret".into()],
contract: None,
allow_reveal: false,
audit_source: "mcp:unknown:1".into(),
pid: 1,
require_agent: false,
vault_path: std::path::PathBuf::from("nonexistent"),
};
assert!(s.is_in_scope("demo/foo"));
assert!(!s.is_in_scope("demo/secret"));
assert!(!s.is_in_scope("other/foo"));
}
#[test]
fn looks_like_glob_detects_meta_chars() {
assert!(looks_like_glob("demo/*"));
assert!(looks_like_glob("foo?"));
assert!(looks_like_glob("a[bc]"));
assert!(!looks_like_glob("demo/foo"));
assert!(!looks_like_glob("DB_HOST"));
}
#[test]
fn helper_builds_args_with_keys() {
let a = args_with_profile_and_keys("p", &["k1", "k2"]);
assert_eq!(a.profile.as_deref(), Some("p"));
assert_eq!(a.allowed_keys, vec!["k1", "k2"]);
assert!(!a.require_agent);
}
#[test]
fn parse_no_require_agent_flag_disables_require_agent() {
temp_env::with_var("TSAFE_MCP_REQUIRE_AGENT", Some("1"), || {
let argv = vec![
"--profile".to_string(),
"demo".to_string(),
"--allowed-keys".to_string(),
"demo/*".to_string(),
"--no-require-agent".to_string(),
];
let parsed = SessionArgs::parse(&argv).unwrap();
assert!(!parsed.require_agent, "--no-require-agent must disable");
});
}
#[test]
fn parse_unknown_flag_returns_invalid_request() {
let argv = vec!["--no-such-flag".to_string()];
let err = SessionArgs::parse(&argv).expect_err("unknown flag must error");
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
assert!(err.message.contains("unknown serve flag"));
}
#[test]
fn parse_flag_missing_value_returns_invalid_request() {
let argv = vec!["--profile".to_string()];
let err = SessionArgs::parse(&argv).expect_err("missing value must error");
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
assert!(err.message.contains("--profile"));
}
#[test]
fn audit_source_default_when_unspecified() {
let pid = std::process::id();
let label = format!("mcp:unknown:{pid}");
assert!(label.starts_with("mcp:unknown:"));
assert!(label.ends_with(&pid.to_string()));
}
#[test]
fn is_in_scope_uses_contract_when_globs_empty() {
use tsafe_core::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
use tsafe_core::rbac::RbacProfile;
let contract = AuthorityContract {
name: "deploy".to_string(),
profile: None,
namespace: None,
access_profile: RbacProfile::ReadWrite,
allowed_secrets: vec!["demo/foo".to_string(), "demo/bar".to_string()],
required_secrets: vec![],
allowed_targets: vec![],
trust: AuthorityTrust::Standard,
network: AuthorityNetworkPolicy::Inherit,
};
let s = Session {
profile: "demo".into(),
allowed_globs: vec![],
denied_globs: vec![],
contract: Some(contract),
allow_reveal: false,
audit_source: "mcp:test:1".into(),
pid: 1,
require_agent: false,
vault_path: std::path::PathBuf::from("nonexistent"),
};
assert!(s.is_in_scope("demo/foo"));
assert!(s.is_in_scope("demo/bar"));
assert!(!s.is_in_scope("demo/secret"));
assert!(!s.is_in_scope("other/anything"));
}
#[test]
fn glob_match_empty_pattern_only_matches_empty_input() {
assert!(glob_match("", ""));
assert!(!glob_match("", "anything"));
}
}