merge: mcp stdio config trust
This commit is contained in:
commit
9b7c4e279d
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2032,6 +2032,7 @@ dependencies = [
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"mlua",
|
"mlua",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_ignored",
|
"serde_ignored",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_ignored = "0.1.14"
|
serde_ignored = "0.1.14"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
secrets = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! via [`PodManifestConfig::merge`] and the final config is converted to
|
//! via [`PodManifestConfig::merge`] and the final config is converted to
|
||||||
//! a validated [`PodManifest`] via `TryFrom`.
|
//! a validated [`PodManifest`] via `TryFrom`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
|
@ -17,10 +17,10 @@ use crate::defaults;
|
||||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
|
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
|
||||||
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
|
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
|
||||||
TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig,
|
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
||||||
WorkerManifest,
|
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
/// Partial-form Pod manifest. Every field is optional; one or more
|
||||||
|
|
@ -57,6 +57,10 @@ pub struct PodManifestConfig {
|
||||||
/// separate step and does not run during config merge.
|
/// separate step and does not run during config merge.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: PluginConfig,
|
pub plugins: PluginConfig,
|
||||||
|
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||||
|
/// never starts a local MCP subprocess.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfigPartial>,
|
pub compaction: Option<CompactionConfigPartial>,
|
||||||
/// First-class web tool opt-in. See [`WebConfig`].
|
/// First-class web tool opt-in. See [`WebConfig`].
|
||||||
|
|
@ -322,6 +326,11 @@ pub enum ResolveError {
|
||||||
MissingField(&'static str),
|
MissingField(&'static str),
|
||||||
#[error("path must be absolute ({field}): {}", .path.display())]
|
#[error("path must be absolute ({field}): {}", .path.display())]
|
||||||
RelativePath { field: &'static str, path: PathBuf },
|
RelativePath { field: &'static str, path: PathBuf },
|
||||||
|
#[error("invalid MCP config ({field}): {message}")]
|
||||||
|
InvalidMcpConfig {
|
||||||
|
field: &'static str,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject manifest fields that were intentionally removed and must not be
|
/// Reject manifest fields that were intentionally removed and must not be
|
||||||
|
|
@ -436,6 +445,11 @@ impl PodManifestConfig {
|
||||||
*dir = join_if_relative(base, dir);
|
*dir = join_if_relative(base, dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for server in &mut self.mcp.stdio_servers {
|
||||||
|
if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd {
|
||||||
|
*path = join_if_relative(base, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,6 +472,7 @@ impl PodManifestConfig {
|
||||||
),
|
),
|
||||||
feature: self.feature.merge(upper.feature),
|
feature: self.feature.merge(upper.feature),
|
||||||
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
||||||
|
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||||
compaction: merge_option(
|
compaction: merge_option(
|
||||||
self.compaction,
|
self.compaction,
|
||||||
upper.compaction,
|
upper.compaction,
|
||||||
|
|
@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
||||||
|
base.stdio_servers.extend(upper.stdio_servers);
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
impl WebConfig {
|
impl WebConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<()
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> {
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
for server in &mcp.stdio_servers {
|
||||||
|
if server.name.trim().is_empty() {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.name",
|
||||||
|
"server name must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if contains_nul(&server.name) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.name",
|
||||||
|
"server name must not contain NUL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !names.insert(server.name.as_str()) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.name",
|
||||||
|
format!(
|
||||||
|
"duplicate stdio server name `{}`",
|
||||||
|
bounded_label(&server.name)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.command.trim().is_empty() {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.command",
|
||||||
|
"command must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if contains_nul(&server.command) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.command",
|
||||||
|
"command must not contain NUL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for arg in &server.args {
|
||||||
|
if contains_nul(arg) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.args",
|
||||||
|
"argument must not contain NUL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.cwd.path",
|
||||||
|
"cwd path must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.cwd.path",
|
||||||
|
"cwd path must be absolute after profile/manifest path resolution",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in &server.env.inherit {
|
||||||
|
validate_env_name("mcp.stdio_server.env.inherit", name)?;
|
||||||
|
}
|
||||||
|
for (name, value) in &server.env.set {
|
||||||
|
validate_env_name("mcp.stdio_server.env.set", name)?;
|
||||||
|
match value {
|
||||||
|
McpEnvValue::Literal { value } => {
|
||||||
|
if contains_nul(value) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.env.set",
|
||||||
|
"literal env value must not contain NUL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpEnvValue::SecretRef { ref_ } => {
|
||||||
|
if secrets::validate_id(ref_).is_err() {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
"mcp.stdio_server.env.set.secret_ref",
|
||||||
|
"secret_ref must be a valid local secret id",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpEnvValue::EnvRef { name } => {
|
||||||
|
validate_env_name("mcp.stdio_server.env.set.env_ref", name)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
let Some(first) = chars.next() else {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
field,
|
||||||
|
"environment variable name must not be empty",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
field,
|
||||||
|
"environment variable name must start with ASCII letter or underscore",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
|
||||||
|
return Err(invalid_mcp(
|
||||||
|
field,
|
||||||
|
"environment variable name must contain only ASCII letters, digits, and underscore",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_mcp(field: &'static str, message: impl Into<String>) -> ResolveError {
|
||||||
|
ResolveError::InvalidMcpConfig {
|
||||||
|
field,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_nul(value: &str) -> bool {
|
||||||
|
value.as_bytes().contains(&0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_label(value: &str) -> String {
|
||||||
|
const MAX: usize = 80;
|
||||||
|
let mut out = String::new();
|
||||||
|
for (idx, ch) in value.chars().enumerate() {
|
||||||
|
if idx >= MAX {
|
||||||
|
out.push('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ch.is_control() {
|
||||||
|
out.push('?');
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
impl TryFrom<PodManifestConfig> for PodManifest {
|
impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
type Error = ResolveError;
|
type Error = ResolveError;
|
||||||
|
|
||||||
|
|
@ -842,6 +1005,8 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_mcp_config(&cfg.mcp)?;
|
||||||
|
|
||||||
Ok(PodManifest {
|
Ok(PodManifest {
|
||||||
pod: PodMeta { name, prompt_pack },
|
pod: PodMeta { name, prompt_pack },
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
|
|
@ -852,6 +1017,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
permissions,
|
permissions,
|
||||||
feature: FeatureConfig::from(cfg.feature),
|
feature: FeatureConfig::from(cfg.feature),
|
||||||
plugins: cfg.plugins,
|
plugins: cfg.plugins,
|
||||||
|
mcp: cfg.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: cfg.web,
|
web: cfg.web,
|
||||||
memory: cfg.memory,
|
memory: cfg.memory,
|
||||||
|
|
@ -899,6 +1065,7 @@ mod tests {
|
||||||
permissions: None,
|
permissions: None,
|
||||||
feature: FeatureConfigPartial::default(),
|
feature: FeatureConfigPartial::default(),
|
||||||
plugins: PluginConfig::default(),
|
plugins: PluginConfig::default(),
|
||||||
|
mcp: McpConfig::default(),
|
||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
web: None,
|
web: None,
|
||||||
|
|
@ -915,6 +1082,139 @@ mod tests {
|
||||||
assert!(manifest.permissions.is_none());
|
assert!(manifest.permissions.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_mcp_stdio_config_preserves_explicit_policy() {
|
||||||
|
let mut cfg = minimal_valid();
|
||||||
|
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
|
||||||
|
name: "filesystem".into(),
|
||||||
|
command: "node".into(),
|
||||||
|
args: vec!["server.js".into(), "--root".into()],
|
||||||
|
cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }),
|
||||||
|
env: crate::McpEnvConfig {
|
||||||
|
inherit: vec!["PATH".into()],
|
||||||
|
set: std::collections::BTreeMap::from([
|
||||||
|
(
|
||||||
|
"SAFE_MODE".into(),
|
||||||
|
McpEnvValue::Literal { value: "1".into() },
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"TOKEN".into(),
|
||||||
|
McpEnvValue::SecretRef {
|
||||||
|
ref_: "providers/mcp-token".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"UPSTREAM".into(),
|
||||||
|
McpEnvValue::EnvRef {
|
||||||
|
name: "MCP_UPSTREAM_TOKEN".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
|
||||||
|
let server = &manifest.mcp.stdio_servers[0];
|
||||||
|
assert_eq!(server.name, "filesystem");
|
||||||
|
assert_eq!(server.command, "node");
|
||||||
|
assert_eq!(server.env.inherit, ["PATH"]);
|
||||||
|
assert!(matches!(
|
||||||
|
server.env.set["TOKEN"],
|
||||||
|
McpEnvValue::SecretRef { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_mcp_rejects_empty_command_and_duplicates() {
|
||||||
|
let mut cfg = minimal_valid();
|
||||||
|
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
|
||||||
|
name: "dup".into(),
|
||||||
|
command: "".into(),
|
||||||
|
args: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
env: crate::McpEnvConfig::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ResolveError::InvalidMcpConfig {
|
||||||
|
field: "mcp.stdio_server.command",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut cfg = minimal_valid();
|
||||||
|
for command in ["one", "two"] {
|
||||||
|
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
|
||||||
|
name: "dup".into(),
|
||||||
|
command: command.into(),
|
||||||
|
args: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
env: crate::McpEnvConfig::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ResolveError::InvalidMcpConfig {
|
||||||
|
field: "mcp.stdio_server.name",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() {
|
||||||
|
let mut cfg = minimal_valid();
|
||||||
|
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
|
||||||
|
name: "secret".into(),
|
||||||
|
command: "no-such-command-is-not-started".into(),
|
||||||
|
args: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
env: crate::McpEnvConfig {
|
||||||
|
inherit: Vec::new(),
|
||||||
|
set: std::collections::BTreeMap::from([(
|
||||||
|
"TOKEN".into(),
|
||||||
|
McpEnvValue::SecretRef {
|
||||||
|
ref_: "bad secret id with spaces".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||||
|
let rendered = err.to_string();
|
||||||
|
assert!(rendered.contains("secret_ref"));
|
||||||
|
assert!(!rendered.contains("bad secret id with spaces"));
|
||||||
|
|
||||||
|
let value = McpEnvValue::Literal {
|
||||||
|
value: "plaintext-secret-value".into(),
|
||||||
|
};
|
||||||
|
assert!(!format!("{value:?}").contains("plaintext-secret-value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_mcp_accepts_nonexistent_command_without_autostart() {
|
||||||
|
let mut cfg = minimal_valid();
|
||||||
|
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
|
||||||
|
name: "later".into(),
|
||||||
|
command: "definitely-not-a-command-yoi-must-spawn".into(),
|
||||||
|
args: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
env: crate::McpEnvConfig::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manifest.mcp.stdio_servers[0].command,
|
||||||
|
"definitely-not-a-command-yoi-must-spawn"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_session_record_event_trace() {
|
fn resolve_session_record_event_trace() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@ pub use profile::{
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fmt;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::de::Error as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Declarative configuration for a Pod.
|
/// Declarative configuration for a Pod.
|
||||||
|
|
@ -62,6 +64,12 @@ pub struct PodManifest {
|
||||||
/// source-qualified entries listed here may resolve to active plugin metadata.
|
/// source-qualified entries listed here may resolve to active plugin metadata.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: plugin::PluginConfig,
|
pub plugins: plugin::PluginConfig,
|
||||||
|
/// Explicit external Model Context Protocol provider configuration. This
|
||||||
|
/// is config data only: declaring a server never starts a subprocess or
|
||||||
|
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
|
||||||
|
/// consumer boundary.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfig>,
|
pub compaction: Option<CompactionConfig>,
|
||||||
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
||||||
|
|
@ -194,6 +202,92 @@ pub struct SkillsConfig {
|
||||||
pub directories: Vec<PathBuf>,
|
pub directories: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit Model Context Protocol configuration.
|
||||||
|
///
|
||||||
|
/// The manifest layer records local stdio MCP server declarations but never
|
||||||
|
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
|
||||||
|
/// process authority separate from Plugin permissions and `pod::feature` flags.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct McpConfig {
|
||||||
|
/// Named local stdio servers. The list form keeps declarations explicit and
|
||||||
|
/// lets validation reject duplicate names after profile/override merging.
|
||||||
|
#[serde(default, rename = "stdio_server")]
|
||||||
|
pub stdio_servers: Vec<McpStdioServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct McpStdioServerConfig {
|
||||||
|
/// Stable profile-local name used by later lifecycle/tool-surface code.
|
||||||
|
pub name: String,
|
||||||
|
/// Executable path/name passed directly to process-spawn code in a later
|
||||||
|
/// ticket. This is not a shell string and is not executed by config parsing.
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Optional working-directory policy for the future subprocess. Omitted
|
||||||
|
/// means no config-level cwd override. Relative `path` values are resolved
|
||||||
|
/// against the manifest/profile layer before final validation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<McpStdioCwdPolicy>,
|
||||||
|
/// Explicit environment policy. There is no implicit environment discovery;
|
||||||
|
/// future spawn code should inherit only names listed here and set only
|
||||||
|
/// entries declared here.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: McpEnvConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum McpStdioCwdPolicy {
|
||||||
|
/// Leave cwd selection to the lifecycle caller.
|
||||||
|
Inherit,
|
||||||
|
/// Use this absolute (after path resolution) working directory.
|
||||||
|
Path { path: PathBuf },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct McpEnvConfig {
|
||||||
|
/// Host environment variable names to copy explicitly at spawn time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inherit: Vec<String>,
|
||||||
|
/// Environment variables to set explicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub set: BTreeMap<String, McpEnvValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum McpEnvValue {
|
||||||
|
/// Literal value. Use only for non-secret values; Debug/diagnostics redact
|
||||||
|
/// it defensively because env values often become credentials over time.
|
||||||
|
Literal { value: String },
|
||||||
|
/// Local secret-store id. The plaintext is resolved only by a future runtime
|
||||||
|
/// consumer and is never loaded during manifest/profile parsing.
|
||||||
|
#[serde(rename = "secret_ref")]
|
||||||
|
SecretRef {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
ref_: String,
|
||||||
|
},
|
||||||
|
/// Name of a host environment variable to read explicitly at spawn time.
|
||||||
|
EnvRef { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for McpEnvValue {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Literal { .. } => f
|
||||||
|
.debug_struct("Literal")
|
||||||
|
.field("value", &"[redacted]")
|
||||||
|
.finish(),
|
||||||
|
Self::SecretRef { ref_ } => f.debug_struct("SecretRef").field("ref_", ref_).finish(),
|
||||||
|
Self::EnvRef { name } => f.debug_struct("EnvRef").field("name", name).finish(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for WebSearch and WebFetch built-in tools.
|
/// Configuration for WebSearch and WebFetch built-in tools.
|
||||||
///
|
///
|
||||||
/// Network tools are fail-closed: absent config or `enabled = false` disables
|
/// Network tools are fail-closed: absent config or `enabled = false` disables
|
||||||
|
|
@ -712,7 +806,10 @@ impl PodManifest {
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||||
config::reject_removed_manifest_fields(s)?;
|
config::reject_removed_manifest_fields(s)?;
|
||||||
toml::from_str(s)
|
let manifest: Self = toml::from_str(s)?;
|
||||||
|
config::validate_mcp_config(&manifest.mcp)
|
||||||
|
.map_err(|error| toml::de::Error::custom(error.to_string()))?;
|
||||||
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ use crate::config::{
|
||||||
use crate::model::{AuthRef, ModelManifest};
|
use crate::model::{AuthRef, ModelManifest};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
|
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
|
||||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
|
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
|
||||||
|
WorkerManifestConfig, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
||||||
|
|
@ -628,6 +629,7 @@ fn resolve_lua_profile_value(
|
||||||
permissions: profile.permissions,
|
permissions: profile.permissions,
|
||||||
feature: profile.feature,
|
feature: profile.feature,
|
||||||
plugins: profile.plugins,
|
plugins: profile.plugins,
|
||||||
|
mcp: profile.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: profile.web,
|
web: profile.web,
|
||||||
memory: profile.memory,
|
memory: profile.memory,
|
||||||
|
|
@ -691,6 +693,8 @@ struct ProfileConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
plugins: PluginConfig,
|
plugins: PluginConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
mcp: McpConfig,
|
||||||
|
#[serde(default)]
|
||||||
compaction: Option<serde_json::Value>,
|
compaction: Option<serde_json::Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
web: Option<WebConfig>,
|
web: Option<WebConfig>,
|
||||||
|
|
@ -1247,6 +1251,16 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for server in &profile.mcp.stdio_servers {
|
||||||
|
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd
|
||||||
|
&& path.is_absolute()
|
||||||
|
{
|
||||||
|
return Err(ProfileError::InvalidProfile(
|
||||||
|
"field `mcp.stdio_server.cwd.path` must be profile-relative in reusable Profiles"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn reject_absolute_auth_file(
|
fn reject_absolute_auth_file(
|
||||||
|
|
@ -1693,6 +1707,66 @@ return profile {
|
||||||
Some("coder")
|
Some("coder")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_profile_resolves_named_mcp_stdio_config_without_starting_command() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let profile = write_profile(
|
||||||
|
tmp.path(),
|
||||||
|
"mcp.lua",
|
||||||
|
r#"
|
||||||
|
local profile = require("yoi.profile")
|
||||||
|
return profile {
|
||||||
|
slug = "mcp",
|
||||||
|
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
|
||||||
|
mcp = {
|
||||||
|
stdio_server = {
|
||||||
|
{
|
||||||
|
name = "filesystem",
|
||||||
|
command = "definitely-not-spawned-during-profile-resolution",
|
||||||
|
args = { "--root", "." },
|
||||||
|
cwd = { kind = "path", path = "servers" },
|
||||||
|
env = {
|
||||||
|
inherit = { "PATH" },
|
||||||
|
set = {
|
||||||
|
SAFE_MODE = { kind = "literal", value = "1" },
|
||||||
|
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
|
||||||
|
FROM_ENV = { kind = "env_ref", name = "MCP_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
std::fs::create_dir(tmp.path().join("servers")).unwrap();
|
||||||
|
let workspace = tmp.path().join("workspace");
|
||||||
|
std::fs::create_dir(&workspace).unwrap();
|
||||||
|
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(&workspace)
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::path(&profile),
|
||||||
|
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let server = &resolved.manifest.mcp.stdio_servers[0];
|
||||||
|
assert_eq!(server.name, "filesystem");
|
||||||
|
assert_eq!(
|
||||||
|
server.command,
|
||||||
|
"definitely-not-spawned-during-profile-resolution"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
server.cwd,
|
||||||
|
Some(McpStdioCwdPolicy::Path { ref path }) if path == &tmp.path().join("servers")
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
server.env.set["API_TOKEN"],
|
||||||
|
crate::McpEnvValue::SecretRef { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_lua_profile_feature_flags_without_runtime_state() {
|
fn resolves_lua_profile_feature_flags_without_runtime_state() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
|
||||||
}),
|
}),
|
||||||
feature: manifest.feature.clone().into(),
|
feature: manifest.feature.clone().into(),
|
||||||
plugins: manifest.plugins.clone(),
|
plugins: manifest.plugins.clone(),
|
||||||
|
mcp: manifest.mcp.clone(),
|
||||||
compaction: manifest
|
compaction: manifest
|
||||||
.compaction
|
.compaction
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,37 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
|
||||||
|
|
||||||
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input.
|
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input.
|
||||||
|
|
||||||
|
## Local stdio MCP server declarations
|
||||||
|
|
||||||
|
Profiles and manifest layers may declare named local stdio MCP servers under `mcp.stdio_server`. This is a typed configuration surface only. Declaring a server does not start a subprocess, discover packages, negotiate MCP capabilities, or register tools/resources/prompts.
|
||||||
|
|
||||||
|
Example Lua Profile fragment:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mcp = {
|
||||||
|
stdio_server = {
|
||||||
|
{
|
||||||
|
name = "filesystem",
|
||||||
|
command = "node",
|
||||||
|
args = { "server.js", "--root", "." },
|
||||||
|
cwd = { kind = "path", path = "./mcp" },
|
||||||
|
env = {
|
||||||
|
inherit = { "PATH" },
|
||||||
|
set = {
|
||||||
|
SAFE_MODE = { kind = "literal", value = "1" },
|
||||||
|
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
|
||||||
|
UPSTREAM_TOKEN = { kind = "env_ref", name = "MCP_UPSTREAM_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`command` is a direct executable name/path, not a shell string. `args` are passed as argv entries by future lifecycle code. `cwd.kind = "path"` is resolved relative to the Profile or manifest layer; omit `cwd` or use `{ kind = "inherit" }` when the lifecycle caller should choose. Environment handling is explicit: future spawn code should inherit only names listed in `env.inherit` and set only variables in `env.set`. `literal` values are for non-secret data; credentials should use `secret_ref` or explicit `env_ref`. Diagnostics and Debug output must redact env literal values and must not print secret plaintext.
|
||||||
|
|
||||||
|
Local stdio MCP servers are ordinary local executables running with the user's OS permissions. Yoi's feature flags, Plugin permissions, and MCP config validation are not an operating-system sandbox and cannot prevent filesystem/network/process side effects once a later lifecycle implementation chooses to spawn a configured server.
|
||||||
|
|
||||||
## Spawned Pods
|
## Spawned Pods
|
||||||
|
|
||||||
`SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope.
|
`SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I=";
|
cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user