mcp: add stdio server config
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
//! via [`PodManifestConfig::merge`] and the final config is converted to
|
||||
//! a validated [`PodManifest`] via `TryFrom`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -17,10 +17,10 @@ use crate::defaults;
|
||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
|
||||
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
|
||||
TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig,
|
||||
WorkerManifest,
|
||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
|
||||
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub plugins: PluginConfig,
|
||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
@@ -322,6 +326,11 @@ pub enum ResolveError {
|
||||
MissingField(&'static str),
|
||||
#[error("path must be absolute ({field}): {}", .path.display())]
|
||||
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
|
||||
@@ -436,6 +445,11 @@ impl PodManifestConfig {
|
||||
*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
|
||||
}
|
||||
|
||||
@@ -458,6 +472,7 @@ impl PodManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon
|
||||
base
|
||||
}
|
||||
|
||||
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
||||
base.stdio_servers.extend(upper.stdio_servers);
|
||||
base
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
@@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<()
|
||||
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 {
|
||||
type Error = ResolveError;
|
||||
|
||||
@@ -842,6 +1005,8 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
}
|
||||
}
|
||||
|
||||
validate_mcp_config(&cfg.mcp)?;
|
||||
|
||||
Ok(PodManifest {
|
||||
pod: PodMeta { name, prompt_pack },
|
||||
model: cfg.model,
|
||||
@@ -852,6 +1017,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
plugins: cfg.plugins,
|
||||
mcp: cfg.mcp,
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
memory: cfg.memory,
|
||||
@@ -899,6 +1065,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
plugins: PluginConfig::default(),
|
||||
mcp: McpConfig::default(),
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
@@ -915,6 +1082,139 @@ mod tests {
|
||||
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]
|
||||
fn resolve_session_record_event_trace() {
|
||||
let mut cfg = minimal_valid();
|
||||
|
||||
@@ -24,10 +24,12 @@ pub use profile::{
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
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::path::PathBuf;
|
||||
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative configuration for a Pod.
|
||||
@@ -62,6 +64,12 @@ pub struct PodManifest {
|
||||
/// source-qualified entries listed here may resolve to active plugin metadata.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
||||
@@ -194,6 +202,92 @@ pub struct SkillsConfig {
|
||||
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.
|
||||
///
|
||||
/// Network tools are fail-closed: absent config or `enabled = false` disables
|
||||
@@ -712,7 +806,10 @@ impl PodManifest {
|
||||
/// Parse a manifest from a TOML string.
|
||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||
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::plugin::PluginConfig;
|
||||
use crate::{
|
||||
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
|
||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
|
||||
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
|
||||
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
|
||||
WorkerManifestConfig, paths,
|
||||
};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
||||
@@ -628,6 +629,7 @@ fn resolve_lua_profile_value(
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
plugins: profile.plugins,
|
||||
mcp: profile.mcp,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
memory: profile.memory,
|
||||
@@ -691,6 +693,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
plugins: PluginConfig,
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
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(())
|
||||
}
|
||||
fn reject_absolute_auth_file(
|
||||
@@ -1693,6 +1707,66 @@ return profile {
|
||||
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]
|
||||
fn resolves_lua_profile_feature_flags_without_runtime_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user