use std::sync::Arc;
use greentic_mcp_exec::SecretsStore;
use greentic_secrets_lib::SecretsManager;
use greentic_types::TenantCtx;
const MCP_CATEGORY: &str = "mcp";
const MCP_ENV_SEGMENT: &str = "default";
pub const MCP_TENANT_DEFAULT_TEAM: &str = "_";
pub fn mcp_secret_uri(tenant: &str, team: Option<&str>, key: &str) -> Result<String, String> {
if key.trim().is_empty() {
return Err("secret key must not be empty".to_string());
}
Ok(format!(
"secrets://{}/{}/{}/{}/{}",
MCP_ENV_SEGMENT,
tenant,
team.unwrap_or(MCP_TENANT_DEFAULT_TEAM),
MCP_CATEGORY,
key
))
}
pub(crate) fn mcp_secret_uri_for_ctx(ctx: &TenantCtx, key: &str) -> Result<String, String> {
let team = ctx
.team_id
.as_ref()
.or(ctx.team.as_ref())
.map(|value| value.as_str());
mcp_secret_uri(ctx.tenant.as_str(), team, key)
}
fn mcp_secret_uri_candidates(
tenant: &str,
team: Option<&str>,
key: &str,
) -> Result<Vec<String>, String> {
let team = team.filter(|t| !t.is_empty() && *t != MCP_TENANT_DEFAULT_TEAM);
let mut uris = Vec::with_capacity(2);
if let Some(team) = team {
uris.push(mcp_secret_uri(tenant, Some(team), key)?);
}
uris.push(mcp_secret_uri(tenant, None, key)?);
Ok(uris)
}
#[derive(Debug, Clone)]
pub struct McpSecretMiss {
pub uris: Vec<String>,
pub error: String,
}
impl std::fmt::Display for McpSecretMiss {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"no credential at {} ({}). Note that MCP requires SECRETS_BACKEND=broker; \
the env backend cannot resolve a secrets:// URI.",
self.uris.join(" or "),
self.error
)
}
}
pub async fn read_mcp_secret(
secrets: &dyn SecretsManager,
tenant: &str,
team: Option<&str>,
key: &str,
) -> Result<Vec<u8>, McpSecretMiss> {
let uris = mcp_secret_uri_candidates(tenant, team, key).map_err(|error| McpSecretMiss {
uris: Vec::new(),
error,
})?;
let mut last_error = String::from("no scope tried");
for uri in &uris {
match secrets.read(uri).await {
Ok(bytes) => return Ok(bytes),
Err(e) => last_error = e.to_string(),
}
}
Err(McpSecretMiss {
uris,
error: last_error,
})
}
pub struct McpSecretsStore {
secrets: Arc<dyn SecretsManager>,
}
impl McpSecretsStore {
pub fn new(secrets: Arc<dyn SecretsManager>) -> Self {
Self { secrets }
}
}
impl SecretsStore for McpSecretsStore {
fn read(&self, scope: &TenantCtx, name: &str) -> Result<Vec<u8>, String> {
let uri = mcp_secret_uri_for_ctx(scope, name)?;
futures::executor::block_on(self.secrets.read(&uri)).map_err(|e| e.to_string())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use async_trait::async_trait;
use greentic_types::{EnvId, TenantId};
use std::sync::Mutex;
struct FakeSecrets {
seen: Mutex<Vec<String>>,
value: Vec<u8>,
}
#[async_trait]
impl SecretsManager for FakeSecrets {
async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
self.seen.lock().unwrap().push(path.to_string());
Ok(self.value.clone())
}
async fn write(&self, _: &str, _: &[u8]) -> greentic_secrets_lib::Result<()> {
Ok(())
}
async fn delete(&self, _: &str) -> greentic_secrets_lib::Result<()> {
Ok(())
}
}
fn ctx() -> TenantCtx {
TenantCtx::new(EnvId::new("prod").unwrap(), TenantId::new("acme").unwrap())
}
#[test]
fn uri_matches_admin_shape_without_team() {
assert_eq!(
mcp_secret_uri_for_ctx(&ctx(), "EXAMPLE_KEY").unwrap(),
"secrets://default/acme/_/mcp/EXAMPLE_KEY"
);
}
#[test]
fn uri_pins_env_to_default_ignoring_flow_env() {
let uri = mcp_secret_uri_for_ctx(&ctx(), "K").unwrap();
assert!(
uri.starts_with("secrets://default/"),
"env must be pinned to `default` to match admin, got: {uri}"
);
}
#[test]
fn uri_preserves_key_case_and_punctuation_verbatim() {
assert_eq!(
mcp_secret_uri_for_ctx(&ctx(), "Petstore-API.key").unwrap(),
"secrets://default/acme/_/mcp/Petstore-API.key"
);
}
#[test]
fn uri_emits_team_segment_verbatim_matching_admin() {
let mut with_team = ctx();
with_team.team_id = Some(greentic_types::TeamId::new("Sales").unwrap());
assert_eq!(
mcp_secret_uri_for_ctx(&with_team, "K").unwrap(),
"secrets://default/acme/Sales/mcp/K"
);
}
#[test]
fn uri_rejects_empty_key() {
assert!(mcp_secret_uri_for_ctx(&ctx(), " ").is_err());
}
#[test]
fn read_uses_the_scoped_uri_and_returns_bytes() {
let fake = Arc::new(FakeSecrets {
seen: Mutex::new(Vec::new()),
value: b"sk-live".to_vec(),
});
let store = McpSecretsStore::new(fake.clone());
let got = store.read(&ctx(), "EXAMPLE_KEY").unwrap();
assert_eq!(got, b"sk-live".to_vec());
assert_eq!(
fake.seen.lock().unwrap().as_slice(),
&["secrets://default/acme/_/mcp/EXAMPLE_KEY".to_string()]
);
}
struct MapSecrets {
entries: std::collections::HashMap<String, Vec<u8>>,
seen: Mutex<Vec<String>>,
}
impl MapSecrets {
fn with(pairs: &[(&str, &str)]) -> Arc<Self> {
Arc::new(Self {
entries: pairs
.iter()
.map(|(k, v)| ((*k).to_string(), v.as_bytes().to_vec()))
.collect(),
seen: Mutex::new(Vec::new()),
})
}
}
#[async_trait]
impl SecretsManager for MapSecrets {
async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
self.seen.lock().unwrap().push(path.to_string());
self.entries
.get(path)
.cloned()
.ok_or_else(|| greentic_secrets_lib::SecretError::NotFound(path.to_string()))
}
async fn write(&self, _: &str, _: &[u8]) -> greentic_secrets_lib::Result<()> {
Ok(())
}
async fn delete(&self, _: &str) -> greentic_secrets_lib::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn read_prefers_the_team_scope_when_it_holds_the_token() {
let secrets = MapSecrets::with(&[
("secrets://default/acme/sales/mcp/srv-1", "team-token"),
("secrets://default/acme/_/mcp/srv-1", "tenant-token"),
]);
let got = read_mcp_secret(secrets.as_ref(), "acme", Some("sales"), "srv-1")
.await
.unwrap();
assert_eq!(got, b"team-token".to_vec());
assert_eq!(
secrets.seen.lock().unwrap().as_slice(),
&["secrets://default/acme/sales/mcp/srv-1".to_string()],
"the tenant-default scope must not be read once the team scope hits"
);
}
#[tokio::test]
async fn read_falls_back_to_the_tenant_default_scope() {
let secrets = MapSecrets::with(&[("secrets://default/acme/_/mcp/srv-1", "tenant-token")]);
let got = read_mcp_secret(secrets.as_ref(), "acme", Some("sales"), "srv-1")
.await
.unwrap();
assert_eq!(got, b"tenant-token".to_vec());
assert_eq!(
secrets.seen.lock().unwrap().as_slice(),
&[
"secrets://default/acme/sales/mcp/srv-1".to_string(),
"secrets://default/acme/_/mcp/srv-1".to_string(),
]
);
}
#[tokio::test]
async fn read_with_no_team_tries_only_the_tenant_default_scope() {
let secrets = MapSecrets::with(&[("secrets://default/acme/_/mcp/srv-1", "tenant-token")]);
assert!(
read_mcp_secret(secrets.as_ref(), "acme", None, "srv-1")
.await
.is_ok()
);
assert_eq!(secrets.seen.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn read_does_not_try_the_underscore_scope_twice() {
let secrets = MapSecrets::with(&[]);
let miss = read_mcp_secret(secrets.as_ref(), "acme", Some("_"), "srv-1")
.await
.unwrap_err();
assert_eq!(miss.uris, vec!["secrets://default/acme/_/mcp/srv-1"]);
}
#[tokio::test]
async fn miss_names_every_uri_tried_and_the_broker_requirement() {
let secrets = MapSecrets::with(&[]);
let miss = read_mcp_secret(secrets.as_ref(), "acme", Some("sales"), "srv-1")
.await
.unwrap_err();
let rendered = miss.to_string();
assert!(
rendered.contains("secrets://default/acme/sales/mcp/srv-1")
&& rendered.contains("secrets://default/acme/_/mcp/srv-1"),
"got: {rendered}"
);
assert!(
rendered.contains("SECRETS_BACKEND=broker"),
"the operator needs the cause, not an opaque NotFound; got: {rendered}"
);
}
}