use std::path::Path;
use serde::Deserialize;
pub const MCP_ROUTES_ENTRY: &str = "assets/mcp-routes.json";
pub const MCP_ENV_SEGMENT: &str = "default";
pub const MCP_DEFAULT_TEAM: &str = "_";
pub const MCP_TOKEN_PREFIX: &str = "mcp_token__";
pub const MCP_URL_PREFIX: &str = "mcp_url__";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct PackMcpRoute {
pub server_id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub transport: String,
#[serde(default)]
pub transport_url: Option<String>,
#[serde(default)]
pub auth_header_name: Option<String>,
}
impl PackMcpRoute {
#[must_use]
pub fn is_http(&self) -> bool {
self.transport != "local-wasm"
}
}
#[must_use]
pub fn routes_from_pack(pack_path: &Path) -> Vec<PackMcpRoute> {
let Ok(file) = std::fs::File::open(pack_path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let Ok(entry) = archive.by_name(MCP_ROUTES_ENTRY) else {
return Vec::new();
};
match serde_json::from_reader::<_, Vec<PackMcpRoute>>(entry) {
Ok(routes) => routes,
Err(error) => {
tracing::warn!(
error = %error,
entry = MCP_ROUTES_ENTRY,
"ignoring malformed MCP route sidecar"
);
Vec::new()
}
}
}
#[must_use]
pub fn mcp_secret_uri(tenant: &str, team: Option<&str>, server_id: &str) -> String {
let team = team.filter(|t| !t.is_empty()).unwrap_or(MCP_DEFAULT_TEAM);
format!("secrets://{MCP_ENV_SEGMENT}/{tenant}/{team}/mcp/{server_id}")
}
#[must_use]
pub fn token_question_id(server_id: &str) -> String {
format!("{MCP_TOKEN_PREFIX}{server_id}")
}
#[must_use]
pub fn server_id_from_token_question(question_id: &str) -> Option<&str> {
question_id.strip_prefix(MCP_TOKEN_PREFIX)
}
pub fn augment_with_mcp_routes(mut form: qa_spec::FormSpec, pack_path: &Path) -> qa_spec::FormSpec {
for route in routes_from_pack(pack_path)
.into_iter()
.filter(PackMcpRoute::is_http)
{
let label = route
.name
.clone()
.unwrap_or_else(|| route.server_id.clone());
if !form
.questions
.iter()
.any(|q| q.id == url_question_id(&route.server_id))
{
form.questions.push(qa_spec::QuestionSpec {
id: url_question_id(&route.server_id),
kind: qa_spec::QuestionType::String,
title: format!("MCP server '{label}' URL"),
title_i18n: None,
description: Some(
"Leave blank to use the URL the pack was built with.".to_string(),
),
description_i18n: None,
required: false,
choices: None,
default_value: route.transport_url.clone(),
secret: false,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: Default::default(),
computed_overridable: false,
});
}
if !form
.questions
.iter()
.any(|q| q.id == token_question_id(&route.server_id))
{
form.questions.push(qa_spec::QuestionSpec {
id: token_question_id(&route.server_id),
kind: qa_spec::QuestionType::String,
title: format!("MCP server '{label}' credential"),
title_i18n: None,
description: Some(format!(
"Sent as the `{}` header. Leave blank if the credential is \
already provisioned for this tenant.",
route.auth_header_name.as_deref().unwrap_or("Authorization")
)),
description_i18n: None,
required: false,
choices: None,
default_value: None,
secret: true,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: Default::default(),
computed_overridable: false,
});
}
}
form
}
#[must_use]
pub fn url_question_id(server_id: &str) -> String {
format!("{MCP_URL_PREFIX}{server_id}")
}
#[must_use]
pub fn server_id_from_url_question(question_id: &str) -> Option<&str> {
question_id.strip_prefix(MCP_URL_PREFIX)
}
pub async fn persist_mcp_secrets(
store: &greentic_secrets_lib::DevStore,
tenant: &str,
team: Option<&str>,
config: &serde_json::Value,
) -> anyhow::Result<Vec<String>> {
let Some(map) = config.as_object() else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
let mut written = Vec::new();
for (key, value) in map {
let Some(server_id) = server_id_from_token_question(key) else {
continue;
};
let text = value.as_str().unwrap_or_default().trim();
if text.is_empty() {
continue;
}
let uri = mcp_secret_uri(tenant, team, server_id);
tracing::info!(
uri = %uri,
value_len = text.len(),
server_id,
"setup secret WRITE (mcp)"
);
entries.push(greentic_secrets_lib::SeedEntry {
uri,
format: greentic_secrets_lib::SecretFormat::Text,
value: greentic_secrets_lib::SeedValue::Text {
text: text.to_string(),
},
description: Some(format!("MCP credential for server {server_id}")),
});
written.push(server_id.to_string());
}
if entries.is_empty() {
return Ok(written);
}
let report = greentic_secrets_lib::apply_seed(
store,
&greentic_secrets_lib::SeedDoc { entries },
greentic_secrets_lib::ApplyOptions::default(),
)
.await;
if !report.failed.is_empty() {
anyhow::bail!(
"failed to persist {} MCP credential(s): {:?}",
report.failed.len(),
report.failed
);
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn pack_with_routes(dir: &std::path::Path, routes_json: &str) -> std::path::PathBuf {
let path = dir.join("t.gtpack");
let file = std::fs::File::create(&path).expect("create pack");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(MCP_ROUTES_ENTRY, zip::write::SimpleFileOptions::default())
.expect("entry");
zip.write_all(routes_json.as_bytes()).expect("write");
zip.finish().expect("finish");
path
}
#[test]
fn the_secret_uri_matches_what_the_runner_reads() {
assert_eq!(
mcp_secret_uri("acme", None, "ff308b9c-951a-40b8-acea-f62cdd19c8f3"),
"secrets://default/acme/_/mcp/ff308b9c-951a-40b8-acea-f62cdd19c8f3"
);
}
#[test]
fn a_team_scoped_uri_carries_the_team() {
assert_eq!(
mcp_secret_uri("acme", Some("payments"), "srv-1"),
"secrets://default/acme/payments/mcp/srv-1"
);
}
#[test]
fn the_server_id_is_never_canonicalized() {
let uri = mcp_secret_uri("acme", None, "FF308B9C-951a-40b8");
assert!(
uri.ends_with("/mcp/FF308B9C-951a-40b8"),
"server id must survive verbatim — case and hyphens included; got {uri}"
);
assert!(
!uri.contains("ff308b9c_951a"),
"canonicalization would corrupt the id and resolve nothing; got {uri}"
);
}
#[test]
fn routes_are_read_from_the_pack_sidecar() {
let dir = tempfile::tempdir().expect("tmp");
let pack = pack_with_routes(
dir.path(),
r#"[{"server_id":"srv-1","name":"demo","transport":"http",
"transport_url":"https://example.test/mcp",
"auth_header_name":"Authorization"}]"#,
);
let routes = routes_from_pack(&pack);
assert_eq!(routes.len(), 1, "one route expected, got {routes:?}");
assert_eq!(routes[0].server_id, "srv-1");
assert_eq!(
routes[0].transport_url.as_deref(),
Some("https://example.test/mcp")
);
assert!(routes[0].is_http());
}
#[test]
fn a_pack_without_a_sidecar_yields_no_routes() {
let dir = tempfile::tempdir().expect("tmp");
let pack = pack_with_routes(dir.path(), "not json at all");
assert!(routes_from_pack(&pack).is_empty());
assert!(routes_from_pack(&dir.path().join("missing.gtpack")).is_empty());
}
#[test]
fn a_pack_whose_only_surface_is_mcp_still_gets_questions() {
let dir = tempfile::tempdir().expect("tmp");
let pack = pack_with_routes(
dir.path(),
r#"[{"server_id":"ff308b9c-951a-40b8","name":"insurance demo",
"transport":"http","transport_url":"https://example.test/mcp",
"auth_header_name":"Authorization"}]"#,
);
let form = crate::setup_to_formspec::pack_to_form_spec(&pack, "meridian-insurance")
.expect("a pack carrying MCP routes must yield a form");
let ids: Vec<&str> = form.questions.iter().map(|q| q.id.as_str()).collect();
assert!(
ids.contains(&"mcp_token__ff308b9c-951a-40b8"),
"credential question missing; got {ids:?}"
);
assert!(
ids.contains(&"mcp_url__ff308b9c-951a-40b8"),
"url question missing; got {ids:?}"
);
let token = form
.questions
.iter()
.find(|q| q.id == "mcp_token__ff308b9c-951a-40b8")
.expect("token question");
assert!(token.secret, "the credential must be marked secret");
assert!(
!token.required,
"a bundle with several MCP servers must not become a mandatory \
interrogation, and an admin-provisioned tenant must still pass"
);
let url = form
.questions
.iter()
.find(|q| q.id == "mcp_url__ff308b9c-951a-40b8")
.expect("url question");
assert_eq!(
url.default_value.as_deref(),
Some("https://example.test/mcp"),
"the sidecar's own URL must pre-fill the answer"
);
}
#[test]
fn a_local_wasm_route_needs_no_http_credential() {
let route = PackMcpRoute {
server_id: "s".into(),
name: None,
transport: "local-wasm".into(),
transport_url: None,
auth_header_name: None,
};
assert!(!route.is_http());
}
#[tokio::test]
async fn a_collected_credential_lands_where_the_runner_reads_it() {
let iso = tempfile::tempdir().expect("iso");
let _override = crate::secrets::test_support::StoreOverride::in_dir(iso.path());
let temp = tempfile::tempdir().expect("tempdir");
let store =
crate::secrets::open_dev_store_for_env(temp.path(), "local").expect("open store");
let server = "ff308b9c-951a-40b8-acea-f62cdd19c8f3";
let config = serde_json::json!({
token_question_id(server): "s3cret",
token_question_id("blank-server"): " ",
"unrelated": "ignored",
});
let written = persist_mcp_secrets(&store, "default", None, &config)
.await
.expect("persist");
assert_eq!(
written,
vec![server.to_string()],
"a blank answer must be skipped, not written as an empty secret"
);
let uri = mcp_secret_uri("default", None, server);
assert_eq!(uri, format!("secrets://default/default/_/mcp/{server}"));
let stored = greentic_secrets_lib::SecretsStore::get(&store, &uri)
.await
.expect("secret readable at the runner's URI");
assert_eq!(String::from_utf8_lossy(&stored), "s3cret");
}
#[test]
fn a_token_question_id_round_trips_the_server_id() {
let id = token_question_id("ff308b9c-951a-40b8");
assert_eq!(
server_id_from_token_question(&id),
Some("ff308b9c-951a-40b8")
);
assert_eq!(server_id_from_token_question("unrelated"), None);
}
}