mcp: add stdio server config

This commit is contained in:
2026-06-20 16:17:38 +09:00
parent b0225e48b8
commit e0680ccee0
8 changed files with 515 additions and 10 deletions
+1
View File
@@ -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 }
+305 -5
View File
@@ -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();
+99 -2
View File
@@ -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)
}
}
+76 -2
View File
@@ -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();