mcp: add stdio server config
This commit is contained in:
parent
b0225e48b8
commit
e0680ccee0
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2032,6 +2032,7 @@ dependencies = [
|
|||
"llm-worker",
|
||||
"mlua",
|
||||
"protocol",
|
||||
"secrets",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] }
|
|||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
sha2 = "0.10"
|
||||
secrets = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
|
|||
}),
|
||||
feature: manifest.feature.clone().into(),
|
||||
plugins: manifest.plugins.clone(),
|
||||
mcp: manifest.mcp.clone(),
|
||||
compaction: manifest
|
||||
.compaction
|
||||
.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.
|
||||
|
||||
## 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
|
||||
|
||||
`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;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I=";
|
||||
cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user