use serde::{Deserialize, Serialize};
use crate::error::{ConfigValidationError, ConfigWarning, Result, ToolkitError};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default)]
pub server: ServerSection,
#[serde(default)]
pub metadata: MetadataSection,
#[serde(default)]
pub database: DatabaseSection,
#[cfg(feature = "http")]
#[serde(default)]
pub backend: Option<BackendSection>,
#[serde(default)]
pub code_mode: Option<CodeModeSection>,
#[serde(default)]
pub tools: Vec<ToolDecl>,
#[serde(default)]
pub config_slots: Vec<ConfigSlotDecl>,
#[serde(default)]
pub prompts: Vec<PromptDecl>,
#[serde(default)]
pub resources: Vec<ResourceDecl>,
#[serde(default)]
pub shared_policy_store: Option<SharedPolicyStoreSection>,
}
impl ServerConfig {
pub fn from_toml(toml_str: &str) -> Result<Self> {
toml::from_str(toml_str).map_err(ToolkitError::Parse)
}
pub fn from_toml_strict_validated(toml_str: &str) -> Result<Self> {
let cfg = Self::from_toml(toml_str)?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> std::result::Result<(), ConfigValidationError> {
warn_if_pattern_checking_unavailable(&self.tools);
if self.server.name.trim().is_empty() {
return Err(ConfigValidationError::EmptyServerName);
}
if self.server.version.trim().is_empty() {
return Err(ConfigValidationError::EmptyServerVersion);
}
for (i, tool) in self.tools.iter().enumerate() {
if tool.name.trim().is_empty() {
return Err(ConfigValidationError::EmptyToolName(i));
}
if tool.declared_kind_count() > 1 {
return Err(ConfigValidationError::AmbiguousToolKind(i));
}
validate_tool_parameters(tool, &self.server.validation)?;
}
for (i, table) in self.database.tables.iter().enumerate() {
if table.name.trim().is_empty() {
return Err(ConfigValidationError::EmptyTableName(i));
}
}
for (i, slot) in self.config_slots.iter().enumerate() {
if slot.key.trim().is_empty() || slot.name.trim().is_empty() {
return Err(ConfigValidationError::EmptyConfigSlotField(i));
}
if slot.kind == ConfigSlotKind::Secret && slot.tested_value.is_some() {
return Err(ConfigValidationError::SecretSlotCarriesTestedValue(i));
}
}
#[cfg(feature = "http")]
if let Some(backend) = &self.backend {
if backend.base_url.trim().is_empty() {
return Err(ConfigValidationError::EmptyBackendBaseUrl);
}
if crate::env_ref::parse_env_ref(&backend.base_url) == Some("") {
return Err(ConfigValidationError::MalformedBackendBaseUrlRef);
}
if let Some(field) = backend.auth.malformed_env_ref_field() {
return Err(ConfigValidationError::MalformedBackendAuthRef(field));
}
}
Ok(())
}
#[must_use]
pub fn lint(&self) -> Vec<ConfigWarning> {
let mut out = Vec::new();
for tool in &self.tools {
lint_tool(tool, &self.server.validation, &mut out);
}
lint_opt_outs(&self.server.validation, &mut out);
out
}
#[cfg(feature = "http")]
#[must_use]
pub fn lint_against_spec(&self, spec: &crate::http::OpenApiSchema) -> Vec<ConfigWarning> {
let mut out = Vec::new();
for tool in &self.tools {
let (Some(path), Some(method)) = (tool.path.as_deref(), tool.method.as_deref()) else {
continue;
};
let template = path.split_once('?').map_or(path, |(p, _)| p);
if spec.operation_for(template, method).is_some() {
continue;
}
out.push(ConfigWarning {
tool: Some(tool.name.clone()),
param: None,
rule: CONFIGURED_TEMPLATE_NOT_IN_SPEC,
detail: format!(
"declares `method = \"{method}\"` and `path = \"{path}\"`, which matches no \
operation in the supplied OpenAPI document. The tool still works and its \
path placeholders still face the unconditional character floor and the \
always-on length cap, but the spec's declared pattern/maxLength narrowing \
is NOT applied to them — a placeholder named differently from the spec's \
own (`{{alias}}` against a declared `{{id}}`) reaches the same endpoint \
with its declaration silently dropped. Spell the path and method exactly \
as the spec declares them, or remove the spec if this endpoint is \
deliberately undocumented."
),
});
}
out
}
#[must_use]
pub(crate) fn validation_report(&self) -> ValidationReport {
let validation = &self.server.validation;
let mut opt_outs = Vec::new();
lint_opt_outs(validation, &mut opt_outs);
ValidationReport {
enforce_input_schema: validation.enforce_input_schema,
default_max_length: validation.default_max_length,
additional_properties: validation.additional_properties,
strict: validation.strict,
tools: self
.tools
.iter()
.map(|t| tool_validation_report(t, validation))
.collect(),
opt_outs: opt_outs.iter().map(ToString::to_string).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidationReport {
pub enforce_input_schema: bool,
pub default_max_length: u64,
pub additional_properties: bool,
pub strict: bool,
pub tools: Vec<ToolValidationReport>,
pub opt_outs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolValidationReport {
pub tool: String,
pub rules: Vec<String>,
}
pub(crate) fn is_string_param(p: &ParamDecl) -> bool {
p.param_type.as_deref().unwrap_or("string") == "string"
}
pub(crate) fn default_cap_applies(
p: &ParamDecl,
position: ParamPosition,
validation: &ValidationSection,
) -> bool {
is_string_param(p) && p.max_length.is_none() && cap_position_applies(position, validation)
}
fn cap_position_applies(position: ParamPosition, validation: &ValidationSection) -> bool {
validation.default_max_length != 0
&& matches!(position, ParamPosition::Path | ParamPosition::Query)
}
fn is_uncapped_string(
p: &ParamDecl,
position: ParamPosition,
validation: &ValidationSection,
) -> bool {
is_string_param(p) && p.max_length.is_none() && !cap_position_applies(position, validation)
}
fn uncapped_reason(position: ParamPosition, validation: &ValidationSection) -> String {
if validation.default_max_length == 0 {
"[server.validation] default_max_length = 0 switches the default cap off for \
EVERY position on this server"
.to_string()
} else {
format!(
"it is in {position:?} position, which the [server.validation] \
default_max_length cap deliberately does not reach (free text must keep \
working)"
)
}
}
fn lint_tool(tool: &ToolDecl, validation: &ValidationSection, out: &mut Vec<ConfigWarning>) {
for p in &tool.parameters {
let position = tool.param_position(&p.name);
if is_uncapped_string(p, position, validation) {
out.push(ConfigWarning {
tool: Some(tool.name.clone()),
param: Some(p.name.clone()),
rule: UNCAPPED_STRING,
detail: format!(
"declares no max_length and no default cap reaches it, so it is \
unbounded: {} — declare an explicit max_length, or set \
[server.validation] strict = true to make this an error",
uncapped_reason(position, validation)
),
});
}
if let Some(declared) = p.max_length {
lint_declared_cap_above_placeholder_floor(tool, p, position, declared, out);
}
}
}
#[cfg(feature = "input-validation")]
fn lint_declared_cap_above_placeholder_floor(
tool: &ToolDecl,
p: &ParamDecl,
position: ParamPosition,
declared: u64,
out: &mut Vec<ConfigWarning>,
) {
if !matches!(position, ParamPosition::Path | ParamPosition::Query) {
return;
}
let floor = pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH as u64;
if declared <= floor {
return;
}
out.push(ConfigWarning {
tool: Some(tool.name.clone()),
param: Some(p.name.clone()),
rule: DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP,
detail: format!(
"declares max_length = {declared} in {position:?} position, but the always-on \
path-placeholder floor refuses at {floor} code points regardless — so the \
effective limit is {floor}, and a refusal would name a limit the published \
inputSchema never advertised. Lower the declared max_length to {floor} or below."
),
});
}
#[cfg(not(feature = "input-validation"))]
fn lint_declared_cap_above_placeholder_floor(
_tool: &ToolDecl,
_p: &ParamDecl,
_position: ParamPosition,
_declared: u64,
_out: &mut Vec<ConfigWarning>,
) {
}
fn lint_opt_outs(validation: &ValidationSection, out: &mut Vec<ConfigWarning>) {
if !validation.enforce_input_schema {
out.push(server_warning(
OPT_OUT_ENFORCE_INPUT_SCHEMA,
"[server.validation] enforce_input_schema = false: declared inputSchema values \
are NOT checked at tools/call time. Explicitly-registered argument validators \
still run — this flag does not disable them."
.to_string(),
));
}
if validation.default_max_length == 0 {
out.push(server_warning(
OPT_OUT_DEFAULT_MAX_LENGTH_ZERO,
"[server.validation] default_max_length = 0: no default maxLength is emitted in \
ANY position, so a path or query string parameter that declares no max_length \
is unbounded in the published schema. The always-on path-placeholder floor is \
unaffected."
.to_string(),
));
}
if validation.additional_properties {
out.push(server_warning(
OPT_OUT_ADDITIONAL_PROPERTIES,
"[server.validation] additional_properties = true: UNDECLARED arguments are \
accepted, re-opening the unknown-argument class for every tool on this server."
.to_string(),
));
}
}
fn server_warning(rule: &'static str, detail: String) -> ConfigWarning {
ConfigWarning {
tool: None,
param: None,
rule,
detail,
}
}
fn tool_validation_report(tool: &ToolDecl, validation: &ValidationSection) -> ToolValidationReport {
ToolValidationReport {
tool: tool.name.clone(),
rules: tool
.parameters
.iter()
.map(|p| render_param_rules(tool, p, validation))
.collect(),
}
}
fn render_param_rules(tool: &ToolDecl, p: &ParamDecl, validation: &ValidationSection) -> String {
let position = tool.param_position(&p.name);
let mut parts = vec![format!("{position:?}")];
if p.required {
parts.push("required".to_string());
}
if p.pattern.is_some() {
parts.push("pattern".to_string());
}
if let Some(format) = &p.format {
parts.push(format!("format={format}"));
}
if let Some(min) = p.min_length {
parts.push(format!("minLength={min}"));
}
if let Some(max) = p.max_length {
parts.push(format!("maxLength={max} (declared)"));
} else if default_cap_applies(p, position, validation) {
parts.push(format!(
"maxLength={} (default)",
validation.default_max_length
));
}
format!("{}: {}", p.name, parts.join(", "))
}
pub const UNCAPPED_STRING: &str = "uncapped-string";
pub const DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP: &str =
"declared-max-length-above-placeholder-cap";
pub const CONFIGURED_TEMPLATE_NOT_IN_SPEC: &str = "configured-template-not-in-spec";
pub const OPT_OUT_ENFORCE_INPUT_SCHEMA: &str = "opt-out-enforce-input-schema";
pub const OPT_OUT_DEFAULT_MAX_LENGTH_ZERO: &str = "opt-out-default-max-length-zero";
pub const OPT_OUT_ADDITIONAL_PROPERTIES: &str = "opt-out-additional-properties";
const MAX_EXACT_INTEGER_BOUND: f64 = 9_007_199_254_740_992.0;
fn validate_tool_parameters(
tool: &ToolDecl,
validation: &ValidationSection,
) -> std::result::Result<(), ConfigValidationError> {
for p in &tool.parameters {
check_param_patterns_non_empty(tool, p)?;
check_param_bounds_representable(tool, p)?;
if validation.strict {
check_param_capped_under_strict(tool, p, validation)?;
}
}
check_path_template_segments(tool)?;
check_tool_input_schema_compiles(tool, validation)
}
fn check_path_template_segments(tool: &ToolDecl) -> std::result::Result<(), ConfigValidationError> {
let Some(path) = tool.path.as_deref() else {
return Ok(());
};
for segment in path.split('/') {
if is_supported_path_segment(segment) {
continue;
}
return Err(ConfigValidationError::MalformedPathTemplateSegment {
tool: tool.name.clone(),
segment: segment.to_string(),
});
}
Ok(())
}
fn is_supported_path_segment(segment: &str) -> bool {
if !segment.contains('{') && !segment.contains('}') {
return true;
}
segment.starts_with('{')
&& segment.ends_with('}')
&& segment.len() > 2
&& !segment[1..segment.len() - 1].contains('{')
&& !segment[1..segment.len() - 1].contains('}')
}
fn check_param_capped_under_strict(
tool: &ToolDecl,
p: &ParamDecl,
validation: &ValidationSection,
) -> std::result::Result<(), ConfigValidationError> {
let position = tool.param_position(&p.name);
if is_uncapped_string(p, position, validation) {
return Err(ConfigValidationError::UncappedStringParam {
tool: tool.name.clone(),
param: p.name.clone(),
});
}
Ok(())
}
fn check_param_patterns_non_empty(
tool: &ToolDecl,
p: &ParamDecl,
) -> std::result::Result<(), ConfigValidationError> {
let declared = [
p.pattern.as_deref(),
p.items.as_ref().and_then(|i| i.pattern.as_deref()),
];
if declared.into_iter().flatten().any(str::is_empty) {
return Err(ConfigValidationError::EmptyParamPattern {
tool: tool.name.clone(),
param: p.name.clone(),
});
}
Ok(())
}
fn check_param_bounds_representable(
tool: &ToolDecl,
p: &ParamDecl,
) -> std::result::Result<(), ConfigValidationError> {
let unrepresentable = [p.minimum, p.maximum]
.into_iter()
.flatten()
.any(|b| !b.is_finite() || b.abs() > MAX_EXACT_INTEGER_BOUND);
if unrepresentable {
return Err(ConfigValidationError::NonFiniteParamBound {
tool: tool.name.clone(),
param: p.name.clone(),
});
}
Ok(())
}
#[cfg(feature = "input-validation")]
fn check_tool_input_schema_compiles(
tool: &ToolDecl,
validation: &ValidationSection,
) -> std::result::Result<(), ConfigValidationError> {
let schema = crate::tools::build_input_schema(tool, validation);
pmcp::server::schema_validation::check_input_schema_compiles(&schema).map_err(|violation| {
ConfigValidationError::UncompilableParamSchema {
tool: tool.name.clone(),
position: violation.pointer,
detail: violation.expected,
}
})
}
#[cfg(not(feature = "input-validation"))]
fn check_tool_input_schema_compiles(
_tool: &ToolDecl,
_validation: &ValidationSection,
) -> std::result::Result<(), ConfigValidationError> {
Ok(())
}
#[cfg(not(feature = "input-validation"))]
fn warn_if_pattern_checking_unavailable(tools: &[ToolDecl]) {
let declares_a_pattern = tools.iter().any(|t| {
t.parameters
.iter()
.any(|p| p.pattern.is_some() || p.items.as_ref().is_some_and(|i| i.pattern.is_some()))
});
if declares_a_pattern {
tracing::warn!(
"this build lacks the `input-validation` feature: declared \
[[tools.parameters]] `pattern` values were NOT checked for compilability at \
config time, and will NOT be enforced at tools/call time either — enable \
`input-validation` to get either"
);
}
}
#[cfg(feature = "input-validation")]
#[allow(clippy::missing_const_for_fn)] fn warn_if_pattern_checking_unavailable(_tools: &[ToolDecl]) {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct ServerSection {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default, rename = "type")]
pub server_type: Option<String>,
#[serde(default)]
pub version: String,
#[serde(default)]
pub is_reference: bool,
#[serde(default)]
pub validation: ValidationSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ValidationSection {
#[serde(default = "default_enforce_input_schema")]
pub enforce_input_schema: bool,
#[serde(default = "default_default_max_length")]
pub default_max_length: u64,
#[serde(default)]
pub additional_properties: bool,
#[serde(default)]
pub strict: bool,
}
const DEFAULT_MAX_LENGTH: u64 = 256;
const fn default_enforce_input_schema() -> bool {
true
}
const fn default_default_max_length() -> u64 {
DEFAULT_MAX_LENGTH
}
impl Default for ValidationSection {
fn default() -> Self {
Self {
enforce_input_schema: default_enforce_input_schema(),
default_max_length: default_default_max_length(),
additional_properties: false,
strict: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct MetadataSection {
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub short_description: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub visibility: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct DatabaseSection {
#[serde(default, rename = "type")]
pub backend_type: Option<String>,
#[serde(default)]
pub database: Option<String>,
#[serde(default)]
pub output_location: Option<String>,
#[serde(default)]
pub workgroup: Option<String>,
#[serde(default)]
pub query_timeout_ms: Option<u64>,
#[serde(default)]
pub tables: Vec<DatabaseTableDecl>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub pool: Option<DatabasePoolSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct DatabaseTableDecl {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct DatabasePoolSection {
#[serde(default)]
pub max_connections: Option<u32>,
#[serde(default)]
pub connection_timeout_seconds: Option<u64>,
}
#[cfg(feature = "http")]
pub use crate::http::auth::AuthConfig;
#[cfg(feature = "http")]
pub use crate::http::client::HttpConfig;
#[cfg(feature = "http")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct BackendSection {
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub http: HttpConfig,
}
#[cfg(feature = "http")]
impl BackendSection {
pub fn resolved_base_url(&self) -> std::result::Result<String, ToolkitError> {
match crate::env_ref::parse_env_ref(&self.base_url) {
None => Ok(self.base_url.clone()),
Some("") => Err(ToolkitError::UnresolvedBaseUrlRef { var: String::new() }),
Some(name) => match std::env::var(name) {
Ok(value) if !value.trim().is_empty() => Ok(value),
_ => Err(ToolkitError::UnresolvedBaseUrlRef {
var: name.to_string(),
}),
},
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct CodeModeSection {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub server_id: Option<String>,
#[serde(default)]
pub allow_writes: bool,
#[serde(default)]
pub allow_deletes: bool,
#[serde(default)]
pub allow_ddl: bool,
#[serde(default)]
pub require_limit: bool,
#[serde(default)]
pub max_limit: Option<u64>,
#[serde(default)]
pub blocked_tables: Vec<String>,
#[serde(default)]
pub sensitive_columns: Vec<String>,
#[serde(default)]
pub auto_approve_levels: Vec<String>,
#[serde(default)]
pub token_ttl_seconds: Option<u64>,
#[serde(default)]
pub token_secret: Option<String>,
#[serde(default)]
pub allow_inline_token_secret_for_dev: bool,
#[serde(default)]
pub limits: Option<CodeModeLimits>,
#[serde(default)]
pub description_notice: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct CodeModeLimits {
#[serde(default)]
pub max_tables_per_query: Option<u32>,
#[serde(default)]
pub max_join_depth: Option<u32>,
#[serde(default)]
pub max_subquery_depth: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct SharedPolicyStoreSection {
#[serde(default)]
pub creates_shared_store: bool,
#[serde(default)]
pub export_to_ssm: bool,
#[serde(default)]
pub ssm_path: Option<String>,
#[serde(default)]
pub templates: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSlotKind {
#[default]
Endpoint,
Secret,
AuthMode,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSlotSuppliedBy {
#[default]
Environment,
Platform,
Runtime,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct ConfigSlotDecl {
#[serde(default)]
pub key: String,
pub kind: ConfigSlotKind,
#[serde(default)]
pub name: String,
#[serde(default)]
pub tested_value: Option<String>,
#[serde(default)]
pub supplied_by: ConfigSlotSuppliedBy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ToolDecl {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub sql: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub ui_resource_uri: Option<String>,
#[serde(default)]
pub parameters: Vec<ParamDecl>,
#[serde(default)]
pub annotations: Option<AnnotationsDecl>,
}
impl ToolDecl {
#[must_use]
pub fn is_script_tool(&self) -> bool {
self.script.is_some()
}
fn declared_kind_count(&self) -> usize {
let is_sql = self.sql.is_some();
let is_single_call = self.path.is_some() || self.method.is_some();
let is_script = self.script.is_some();
usize::from(is_sql) + usize::from(is_single_call) + usize::from(is_script)
}
#[must_use]
pub fn param_position(&self, param_name: &str) -> ParamPosition {
if let Some(path) = self.path.as_deref() {
if path_placeholder_names(path).any(|n| n == param_name) {
return ParamPosition::Path;
}
}
match self.method.as_deref() {
Some(m) if method_carries_request_body(m) => ParamPosition::Body,
Some(_) => ParamPosition::Query,
None => ParamPosition::Body,
}
}
}
const BODY_BEARING_METHODS: [&str; 3] = ["POST", "PUT", "PATCH"];
pub(crate) fn method_carries_request_body(method: &str) -> bool {
BODY_BEARING_METHODS.contains(&method.to_uppercase().as_str())
}
pub(crate) fn path_placeholder_names(path: &str) -> impl Iterator<Item = &str> {
path.split('/')
.filter(|s| s.starts_with('{') && s.ends_with('}') && s.len() > 2)
.map(|s| &s[1..s.len() - 1])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParamPosition {
Path,
Query,
Body,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ParamDecl {
#[serde(default)]
pub name: String,
#[serde(default, rename = "type")]
pub param_type: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<toml::Value>,
#[serde(default)]
pub max_length: Option<u64>,
#[serde(default)]
pub minimum: Option<f64>,
#[serde(default)]
pub maximum: Option<f64>,
#[serde(default, rename = "enum")]
pub enum_values: Option<Vec<toml::Value>>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub min_length: Option<u64>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub items: Option<ItemsDecl>,
#[serde(default)]
pub max_items: Option<u64>,
#[serde(default)]
pub allow_slash: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct ItemsDecl {
#[serde(default, rename = "type")]
pub item_type: Option<String>,
#[serde(default)]
pub max_length: Option<u64>,
#[serde(default)]
pub pattern: Option<String>,
}
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct AnnotationsDecl {
#[serde(default)]
pub read_only_hint: bool,
#[serde(default)]
pub destructive_hint: bool,
#[serde(default)]
pub idempotent_hint: bool,
#[serde(default)]
pub open_world_hint: bool,
#[serde(default)]
pub cost_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct PromptDecl {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub include_resources: Vec<String>,
#[serde(default)]
pub arguments: Vec<PromptArgumentDecl>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct PromptArgumentDecl {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct ResourceDecl {
#[serde(default)]
pub uri: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub mime_type: Option<String>,
#[serde(default)]
pub content: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const MINIMAL: &str = r#"
[server]
name = "demo"
version = "0.1.0"
"#;
#[test]
fn parse_minimal_config_succeeds() {
let cfg = ServerConfig::from_toml(MINIMAL).expect("minimal must parse");
assert_eq!(cfg.server.name, "demo");
assert_eq!(cfg.server.version, "0.1.0");
assert!(cfg.tools.is_empty());
assert!(cfg.code_mode.is_none());
}
#[test]
fn parse_unknown_field_fails() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
unknown_field = "x"
"#;
let err = ServerConfig::from_toml(toml).expect_err("unknown field must fail");
assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
}
#[test]
fn parse_typo_in_code_mode_key_fails() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[code_mode]
enabled = true
auto_aprove_levels = ["low"]
"#;
let err = ServerConfig::from_toml(toml).expect_err("typo'd code_mode key must be rejected");
assert!(matches!(err, ToolkitError::Parse(_)));
}
#[test]
fn code_mode_section_optional() {
let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
assert!(cfg.code_mode.is_none());
}
#[test]
fn validate_accepts_valid_config() {
let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
cfg.validate().expect("minimal config must validate");
}
#[test]
fn validate_rejects_empty_server_name() {
let toml = r#"
[server]
name = ""
version = "0.1.0"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyServerName) => {},
other => panic!("expected EmptyServerName, got {other:?}"),
}
}
#[test]
fn validate_rejects_empty_server_version() {
let toml = r#"
[server]
name = "demo"
version = ""
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyServerVersion) => {},
other => panic!("expected EmptyServerVersion, got {other:?}"),
}
}
#[test]
fn validate_rejects_empty_tool_name() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "ok"
description = "first"
[[tools]]
name = ""
description = "second-is-empty"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyToolName(1)) => {},
other => panic!("expected EmptyToolName(1), got {other:?}"),
}
}
#[test]
fn validate_rejects_empty_table_name() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[database.tables]]
name = ""
description = "missing-name"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyTableName(0)) => {},
other => panic!("expected EmptyTableName(0), got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_empty_backend_base_url() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = ""
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_omitted_backend_base_url() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_multi_placeholder_backend_base_url() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "${TFL_SCHEME}://${TFL_HOST}"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_empty_name_backend_base_url_ref() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "${}"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_accepts_single_reference_backend_base_url() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "${TFL_BASE_URL}"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
cfg.validate()
.expect("a single ${VAR} backend.base_url reference must validate");
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_malformed_backend_auth_credential_ref() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
[backend.auth]
type = "bearer"
token = "${TFL-APP-KEY}"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
assert_eq!(field, "token");
},
other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_rejects_malformed_backend_auth_api_key_entry() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
[backend.auth]
type = "api_key"
query_params = { app_key = "${TFL_SCHEME}://${TFL_HOST}" }
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
assert_eq!(field, "query_params.app_key");
},
other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
}
}
#[cfg(feature = "http")]
#[test]
fn validate_accepts_wellformed_and_literal_backend_auth_credentials() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
[backend.auth]
type = "basic"
username = "svc-account"
password = "${TFL_APP_KEY}"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
cfg.validate()
.expect("a literal username and a single ${VAR} password must validate");
}
#[cfg(feature = "http")]
#[test]
fn validate_accepts_non_empty_backend_base_url() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
cfg.validate()
.expect("config with a non-empty backend.base_url must validate");
}
#[cfg(feature = "http")]
#[test]
fn validate_accepts_absent_backend() {
let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
assert!(cfg.backend.is_none());
cfg.validate()
.expect("a config without [backend] must validate (SQL configs unaffected)");
}
#[cfg(feature = "http")]
#[test]
fn empty_backend_base_url_error_names_the_field() {
let msg = ConfigValidationError::EmptyBackendBaseUrl.to_string();
assert!(
msg.contains("[backend].base_url"),
"error must name the field, got: {msg}"
);
}
#[test]
fn database_url_optional_field_parses() {
let toml = r#"
[server]
name = "x"
version = "0.0.1"
[database]
url = "env:DATABASE_URL"
"#;
let cfg = ServerConfig::from_toml(toml).expect("config with [database].url must parse");
assert_eq!(cfg.database.url, Some("env:DATABASE_URL".to_string()));
}
#[test]
fn from_toml_strict_validated_rolls_both_errors() {
let bad_toml = r#"
[server]
name = "demo"
version = "0.1.0"
nonsense = "x"
"#;
let err = ServerConfig::from_toml_strict_validated(bad_toml)
.expect_err("unknown field must surface");
assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
let invalid_toml = r#"
[server]
name = ""
version = "0.1.0"
"#;
let err = ServerConfig::from_toml_strict_validated(invalid_toml)
.expect_err("empty name must surface");
assert!(
matches!(
err,
ToolkitError::Validation(ConfigValidationError::EmptyServerName)
),
"got: {err:?}"
);
}
#[test]
fn test_tooldecl_single_call_parses() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[tools]]
name = "tube_status"
path = "/Line/Mode/tube/Status"
method = "GET"
"#;
let cfg = ServerConfig::from_toml(toml).expect("single-call tool must parse");
let tool = &cfg.tools[0];
assert_eq!(tool.path.as_deref(), Some("/Line/Mode/tube/Status"));
assert_eq!(tool.method.as_deref(), Some("GET"));
assert!(!tool.is_script_tool());
cfg.validate()
.expect("single-call tool is a valid single kind");
}
#[test]
fn test_tooldecl_script_parses() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[tools]]
name = "plan_journey"
script = """
const a = await api.get('/Journey/JourneyResults/' + args.from + '/to/' + args.to);
return a;
"""
[[tools.parameters]]
name = "from"
type = "string"
required = true
[[tools.parameters]]
name = "to"
type = "string"
required = true
"#;
let cfg = ServerConfig::from_toml(toml).expect("script tool must parse");
let tool = &cfg.tools[0];
assert!(tool.script.is_some());
assert!(tool.is_script_tool());
assert_eq!(tool.parameters.len(), 2);
cfg.validate().expect("script tool is a valid single kind");
}
#[test]
fn test_tooldecl_detection() {
let script = ToolDecl {
script: Some("return 1;".to_string()),
..Default::default()
};
assert!(script.is_script_tool());
let single = ToolDecl {
path: Some("/x".to_string()),
method: Some("GET".to_string()),
..Default::default()
};
assert!(!single.is_script_tool());
let sql = ToolDecl {
sql: Some("SELECT 1".to_string()),
..Default::default()
};
assert!(!sql.is_script_tool());
}
#[test]
fn test_tooldecl_ambiguous_rejected() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[tools]]
name = "confused"
path = "/x"
method = "GET"
script = "return 1;"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse (ambiguity is a validate-time rule)");
match cfg.validate() {
Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
}
}
#[test]
fn test_tooldecl_ambiguous_sql_plus_script_rejected() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[tools]]
name = "confused"
sql = "SELECT 1"
script = "return 1;"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
}
}
#[test]
fn test_tooldecl_sql_still_parses() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "list_tables"
sql = "SELECT name FROM sqlite_master"
"#;
let cfg = ServerConfig::from_toml(toml).expect("sql tool must still parse");
let tool = &cfg.tools[0];
assert_eq!(tool.sql.as_deref(), Some("SELECT name FROM sqlite_master"));
assert!(tool.path.is_none());
assert!(tool.method.is_none());
assert!(tool.base_url.is_none());
assert!(tool.script.is_none());
assert!(!tool.is_script_tool());
cfg.validate().expect("sql tool validates as a single kind");
}
#[cfg(feature = "http")]
#[test]
fn test_backend_section_parses() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[backend]
base_url = "https://api.tfl.gov.uk"
[backend.auth]
type = "api_key"
[backend.auth.query_params]
app_key = "${TFL_APP_KEY}"
[backend.http]
timeout_seconds = 10
retries = 2
"#;
let cfg = ServerConfig::from_toml(toml).expect("[backend] config must parse");
let backend = cfg.backend.expect("backend must be Some");
assert_eq!(backend.base_url, "https://api.tfl.gov.uk");
assert_eq!(backend.http.timeout_seconds, 10);
assert_eq!(backend.http.retries, 2);
assert!(
matches!(backend.auth, AuthConfig::ApiKey { .. }),
"auth must be api_key, got {:?}",
backend.auth
);
}
#[cfg(feature = "http")]
#[test]
fn test_backend_auth_defaults_to_none() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
"#;
let cfg = ServerConfig::from_toml(toml).expect("backend w/o auth must parse");
let backend = cfg.backend.expect("backend must be Some");
assert!(matches!(backend.auth, AuthConfig::None));
assert_eq!(backend.http, HttpConfig::default());
}
#[cfg(feature = "http")]
#[test]
fn test_sql_config_unaffected() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[database]
type = "sqlite"
file_path = "/tmp/demo.db"
[[tools]]
name = "list_tables"
sql = "SELECT name FROM sqlite_master"
"#;
let cfg = ServerConfig::from_toml(toml).expect("SQL config must still parse");
assert!(
cfg.backend.is_none(),
"SQL config must have backend == None"
);
assert_eq!(cfg.tools.len(), 1);
}
#[cfg(feature = "http")]
#[test]
fn test_backend_unknown_field_rejected() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[backend]
base_url = "https://api.example.com"
[backend.http]
foo = 1
"#;
let err =
ServerConfig::from_toml(toml).expect_err("unknown [backend.http] key must be rejected");
assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
}
const CONFIG_SLOTS_TOML: &str = r#"
[server]
name = "london-tube"
version = "1.1.0"
[[config_slots]]
key = "backend.base_url"
kind = "endpoint"
name = "TFL_BASE_URL"
tested_value = "https://api.tfl.gov.uk"
[[config_slots]]
key = "backend.auth.query_params.app_key"
kind = "secret"
name = "TFL_APP_KEY"
[[config_slots]]
key = "backend.auth.type"
kind = "auth_mode"
name = "backend-auth-mode"
tested_value = "api_key"
"#;
#[test]
fn config_slots_block_parses_through_strict_entry_point() {
let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
.expect("[[config_slots]] must parse through the strict entry point");
assert_eq!(cfg.config_slots.len(), 3, "three declared slots");
assert_eq!(cfg.config_slots[0].key, "backend.base_url");
assert_eq!(cfg.config_slots[0].kind, ConfigSlotKind::Endpoint);
assert_eq!(cfg.config_slots[0].name, "TFL_BASE_URL");
assert_eq!(
cfg.config_slots[0].tested_value.as_deref(),
Some("https://api.tfl.gov.uk")
);
assert_eq!(cfg.config_slots[1].kind, ConfigSlotKind::Secret);
assert_eq!(cfg.config_slots[1].name, "TFL_APP_KEY");
assert_eq!(cfg.config_slots[2].kind, ConfigSlotKind::AuthMode);
}
#[test]
fn a_config_slot_declaring_supplied_by_parses_through_the_strict_entry_point() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[config_slots]]
key = "backend.base_url"
kind = "endpoint"
name = "TFL_BASE_URL"
tested_value = "https://api.tfl.gov.uk"
supplied_by = "platform"
[[config_slots]]
key = "backend.function_name"
kind = "secret"
name = "AWS_LAMBDA_FUNCTION_NAME"
supplied_by = "runtime"
"#;
let cfg = ServerConfig::from_toml_strict_validated(toml)
.expect("`supplied_by` must parse under deny_unknown_fields");
assert_eq!(
cfg.config_slots[0].supplied_by,
ConfigSlotSuppliedBy::Platform
);
assert_eq!(
cfg.config_slots[1].supplied_by,
ConfigSlotSuppliedBy::Runtime
);
}
#[test]
fn a_config_slot_without_supplied_by_defaults_to_environment() {
let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
.expect("the pre-existing fixture must still parse");
for slot in &cfg.config_slots {
assert_eq!(slot.supplied_by, ConfigSlotSuppliedBy::Environment);
}
}
#[test]
fn an_unknown_supplied_by_value_is_a_parse_error() {
let toml = r#"
[server]
name = "tube"
version = "0.1.0"
[[config_slots]]
key = "backend.base_url"
kind = "endpoint"
name = "TFL_BASE_URL"
tested_value = "x"
supplied_by = "platfrom"
"#;
ServerConfig::from_toml_strict_validated(toml)
.expect_err("a misspelled supplied_by must not silently default");
}
#[test]
fn config_without_config_slots_parses_with_empty_vec() {
let cfg = ServerConfig::from_toml_strict_validated(MINIMAL)
.expect("a config omitting [[config_slots]] still parses");
assert!(
cfg.config_slots.is_empty(),
"absent block yields an empty vec, not a default entry"
);
}
#[test]
fn top_level_config_slots_typo_is_still_rejected() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slotz]]
key = "backend.base_url"
kind = "endpoint"
name = "TFL_BASE_URL"
"#;
let err = ServerConfig::from_toml(toml)
.expect_err("a typo'd top-level array-of-tables must be rejected");
assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
}
#[test]
fn config_slot_unknown_inner_key_is_rejected() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slots]]
key = "backend.base_url"
kind = "endpoint"
nmae = "TFL_BASE_URL"
"#;
let err = ServerConfig::from_toml(toml)
.expect_err("an unknown key inside [[config_slots]] must be rejected");
assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
}
#[test]
fn config_slot_tested_value_is_optional() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slots]]
key = "backend.auth.query_params.app_key"
kind = "secret"
name = "TFL_APP_KEY"
"#;
let cfg = ServerConfig::from_toml_strict_validated(toml)
.expect("an entry without tested_value parses");
assert_eq!(cfg.config_slots.len(), 1);
assert!(
cfg.config_slots[0].tested_value.is_none(),
"omitted tested_value parses to None"
);
}
#[test]
fn config_slot_invalid_kind_is_rejected_naming_the_accepted_set() {
for bad in ["endpont", ""] {
let toml = format!(
r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slots]]
key = "backend.base_url"
kind = "{bad}"
name = "TFL_BASE_URL"
"#
);
let err = ServerConfig::from_toml(&toml)
.expect_err("an unrecognized config-slot kind must be rejected at parse time");
let rendered = err.to_string();
for accepted in ["endpoint", "secret", "auth_mode"] {
assert!(
rendered.contains(accepted),
"the error for kind = \"{bad}\" must name the accepted kind \
`{accepted}`: {rendered}"
);
}
}
}
#[test]
fn config_slot_all_three_kinds_parse_as_a_closed_enum() {
let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
.expect("all three kinds parse");
let kinds: Vec<ConfigSlotKind> = cfg.config_slots.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
vec![
ConfigSlotKind::Endpoint,
ConfigSlotKind::Secret,
ConfigSlotKind::AuthMode
],
"kind is a closed enum, not a free string"
);
}
#[test]
fn config_slot_empty_key_or_name_fails_validation() {
for field in ["key", "name"] {
let (key, name) = if field == "key" {
(" ", "TFL_BASE_URL")
} else {
("backend.base_url", " ")
};
let toml = format!(
r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slots]]
key = "{key}"
kind = "endpoint"
name = "{name}"
"#
);
let cfg = ServerConfig::from_toml(&toml).expect("parses; emptiness is semantic");
let err = cfg
.validate()
.expect_err("an empty config-slot key/name must fail validation");
assert!(
matches!(err, ConfigValidationError::EmptyConfigSlotField(0)),
"empty {field} must yield EmptyConfigSlotField(0), got: {err:?}"
);
}
}
#[test]
fn config_slot_secret_with_tested_value_fails_validation_without_echoing_it() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[config_slots]]
key = "backend.auth.query_params.app_key"
kind = "secret"
name = "TFL_APP_KEY"
tested_value = "sentinel-real-credential"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parses; the rule is semantic");
let err = cfg
.validate()
.expect_err("a secret slot carrying a tested_value must fail validation");
assert!(
matches!(err, ConfigValidationError::SecretSlotCarriesTestedValue(0)),
"got: {err:?}"
);
assert!(
!err.to_string().contains("sentinel-real-credential"),
"the error must not echo the value: {err}"
);
}
#[test]
fn param_decl_parses_all_d2_keys() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "batch_lookup"
[[tools.parameters]]
name = "codes"
type = "array"
required = true
max_items = 25
min_length = 2
format = "uuid"
pattern = "^[A-Z]{3}$"
allow_slash = true
[tools.parameters.items]
type = "string"
max_length = 8
pattern = "^[a-z]+$"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
let p = &cfg.tools[0].parameters[0];
assert_eq!(p.pattern.as_deref(), Some("^[A-Z]{3}$"));
assert_eq!(p.min_length, Some(2));
assert_eq!(p.format.as_deref(), Some("uuid"));
assert_eq!(p.max_items, Some(25));
assert!(p.allow_slash);
let items = p.items.as_ref().expect("items sub-table");
assert_eq!(items.item_type.as_deref(), Some("string"));
assert_eq!(items.max_length, Some(8));
assert_eq!(items.pattern.as_deref(), Some("^[a-z]+$"));
}
#[test]
fn param_decl_d2_keys_round_trip_through_toml() {
let original = ParamDecl {
name: "codes".to_string(),
param_type: Some("array".to_string()),
required: true,
pattern: Some("^[A-Z]{3}$".to_string()),
min_length: Some(2),
format: Some("uuid".to_string()),
max_items: Some(25),
allow_slash: true,
items: Some(ItemsDecl {
item_type: Some("string".to_string()),
max_length: Some(8),
pattern: Some("^[a-z]+$".to_string()),
}),
..Default::default()
};
let cfg = ServerConfig {
server: ServerSection {
name: "demo".to_string(),
version: "0.1.0".to_string(),
..Default::default()
},
tools: vec![ToolDecl {
name: "batch_lookup".to_string(),
parameters: vec![original.clone()],
..Default::default()
}],
..Default::default()
};
let text = toml::to_string(&cfg).expect("serialize");
let parsed = ServerConfig::from_toml(&text).expect("re-parse");
assert_eq!(parsed.tools[0].parameters[0], original);
}
#[test]
fn validate_rejects_uncompilable_param_pattern() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "lookup"
[[tools.parameters]]
name = "region"
type = "string"
pattern = "^[A-Z"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::UncompilableParamSchema {
ref tool,
ref position,
ref detail,
}) => {
assert_eq!(tool, "lookup");
assert!(
position.contains("region"),
"position must name the offending parameter, got {position:?}"
);
assert!(
!detail.is_empty(),
"the author-facing detail must be present"
);
},
other => panic!("expected UncompilableParamSchema, got {other:?}"),
}
}
#[test]
fn validate_accepts_unsatisfiable_but_compilable_param_pattern() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "lookup"
[[tools.parameters]]
name = "region"
type = "string"
pattern = "$^"
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
cfg.validate()
.expect("an unsatisfiable pattern still compiles and must validate");
}
#[test]
fn validate_rejects_empty_param_pattern() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "lookup"
[[tools.parameters]]
name = "region"
type = "string"
pattern = ""
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::EmptyParamPattern {
ref tool,
ref param,
}) => {
assert_eq!(tool, "lookup");
assert_eq!(param, "region");
},
other => panic!("expected EmptyParamPattern, got {other:?}"),
}
}
#[test]
fn validate_rejects_non_finite_param_bound() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "lookup"
[[tools.parameters]]
name = "count"
type = "integer"
maximum = 1e300
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
match cfg.validate() {
Err(ConfigValidationError::NonFiniteParamBound {
ref tool,
ref param,
}) => {
assert_eq!(tool, "lookup");
assert_eq!(param, "count");
},
other => panic!("expected NonFiniteParamBound, got {other:?}"),
}
}
#[test]
fn validate_accepts_param_bound_at_the_representable_boundary() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "lookup"
[[tools.parameters]]
name = "count"
type = "integer"
maximum = 9007199254740992
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
cfg.validate()
.expect("a bound exactly at 2^53 is representable and must validate");
}
#[test]
fn validation_section_parses_all_four_keys() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[server.validation]
enforce_input_schema = false
default_max_length = 64
additional_properties = true
strict = true
"#;
let cfg = ServerConfig::from_toml(toml).expect("parse");
let v = &cfg.server.validation;
assert!(!v.enforce_input_schema);
assert_eq!(v.default_max_length, 64);
assert!(v.additional_properties);
assert!(v.strict);
}
#[test]
fn validation_section_absent_keys_yield_documented_defaults() {
let partial = r#"
[server]
name = "demo"
version = "0.1.0"
[server.validation]
default_max_length = 10
"#;
let cfg = ServerConfig::from_toml(partial).expect("parse");
let v = &cfg.server.validation;
assert!(v.enforce_input_schema, "default is ON");
assert_eq!(v.default_max_length, 10);
assert!(!v.additional_properties, "default is a closed envelope");
assert!(!v.strict, "default must not refuse to boot");
let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
assert_eq!(cfg.server.validation, ValidationSection::default());
assert!(cfg.server.validation.enforce_input_schema);
assert_eq!(cfg.server.validation.default_max_length, 256);
}
#[test]
fn validation_section_rejects_an_unknown_key() {
let toml = r#"
[server]
name = "demo"
version = "0.1.0"
[server.validation]
enforce_input_schemas = false
"#;
let err = ServerConfig::from_toml(toml)
.expect_err("a typo'd opt-out key must not be silently ignored");
assert!(matches!(err, ToolkitError::Parse(_)), "got {err:?}");
}
#[test]
fn param_position_agrees_with_build_operation_on_path_parameters() {
let get_tool = ToolDecl {
name: "line_status".to_string(),
path: Some("/lines/{line_id}/status".to_string()),
method: Some("GET".to_string()),
parameters: vec![
ParamDecl {
name: "line_id".to_string(),
..Default::default()
},
ParamDecl {
name: "detail".to_string(),
..Default::default()
},
],
..Default::default()
};
let path_names: Vec<&str> =
path_placeholder_names(get_tool.path.as_deref().expect("path")).collect();
assert_eq!(path_names, vec!["line_id"]);
for p in &get_tool.parameters {
let expected = if path_names.contains(&p.name.as_str()) {
ParamPosition::Path
} else {
ParamPosition::Query
};
assert_eq!(
get_tool.param_position(&p.name),
expected,
"position for {} must agree with the path split",
p.name
);
}
let post_tool = ToolDecl {
name: "add_comment".to_string(),
path: Some("/issues/{id}/comments".to_string()),
method: Some("POST".to_string()),
..Default::default()
};
assert_eq!(post_tool.param_position("id"), ParamPosition::Path);
assert_eq!(post_tool.param_position("body_text"), ParamPosition::Body);
let options_tool = ToolDecl {
name: "probe".to_string(),
path: Some("/issues/{id}".to_string()),
method: Some("OPTIONS".to_string()),
..Default::default()
};
assert_eq!(options_tool.param_position("id"), ParamPosition::Path);
assert_eq!(options_tool.param_position("detail"), ParamPosition::Query);
let sql_tool = ToolDecl {
name: "q".to_string(),
sql: Some("SELECT :id".to_string()),
..Default::default()
};
assert_eq!(sql_tool.param_position("id"), ParamPosition::Body);
}
#[test]
fn path_placeholder_names_matches_whole_segments_only() {
let names: Vec<&str> = path_placeholder_names("/a/{}/b/{id}/c/pre{mid}post").collect();
assert_eq!(names, vec!["id"]);
}
#[test]
fn lint_returns_empty_vec_for_a_config_with_zero_tools() {
let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
assert_eq!(cfg.lint(), Vec::new());
}
fn cfg_with_one_tool(tool: ToolDecl, validation: ValidationSection) -> ServerConfig {
ServerConfig {
server: ServerSection {
name: "demo".to_string(),
version: "0.1.0".to_string(),
validation,
..Default::default()
},
tools: vec![tool],
..Default::default()
}
}
#[test]
fn lint_reports_one_uncapped_string_finding_per_body_parameter() {
let cfg = cfg_with_one_tool(
ToolDecl {
name: "search_tracks".to_string(),
sql: Some("SELECT 1".to_string()),
parameters: vec![ParamDecl {
name: "q".to_string(),
param_type: Some("string".to_string()),
..Default::default()
}],
..Default::default()
},
ValidationSection::default(),
);
let findings = cfg.lint();
assert_eq!(findings.len(), 1, "got {findings:?}");
assert_eq!(findings[0].rule, UNCAPPED_STRING);
assert_eq!(findings[0].tool.as_deref(), Some("search_tracks"));
assert_eq!(findings[0].param.as_deref(), Some("q"));
cfg.validate()
.expect("a lint finding must not fail validate");
}
#[test]
fn lint_returns_findings_in_declaration_order() {
let cfg = cfg_with_one_tool(
ToolDecl {
name: "note".to_string(),
sql: Some("SELECT 1".to_string()),
parameters: vec![
ParamDecl {
name: "zebra".to_string(),
param_type: Some("string".to_string()),
..Default::default()
},
ParamDecl {
name: "alpha".to_string(),
param_type: Some("string".to_string()),
..Default::default()
},
],
..Default::default()
},
ValidationSection::default(),
);
let findings = cfg.lint();
assert_eq!(findings.len(), 2, "got {findings:?}");
assert_eq!(findings[0].param.as_deref(), Some("zebra"));
assert_eq!(findings[1].param.as_deref(), Some("alpha"));
}
#[test]
fn lint_skips_a_path_parameter_covered_by_the_default_cap() {
let cfg = cfg_with_one_tool(
ToolDecl {
name: "add_comment".to_string(),
path: Some("/issues/{id}/comments".to_string()),
method: Some("POST".to_string()),
parameters: vec![
ParamDecl {
name: "body_text".to_string(),
param_type: Some("string".to_string()),
..Default::default()
},
ParamDecl {
name: "id".to_string(),
param_type: Some("string".to_string()),
..Default::default()
},
],
..Default::default()
},
ValidationSection::default(),
);
let findings = cfg.lint();
assert_eq!(findings.len(), 1, "got {findings:?}");
assert_eq!(findings[0].param.as_deref(), Some("body_text"));
assert_eq!(findings[0].rule, UNCAPPED_STRING);
}
#[cfg(feature = "input-validation")]
#[test]
fn lint_reports_a_declared_max_length_above_the_placeholder_floor() {
let floor = pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH as u64;
let cfg = cfg_with_one_tool(
ToolDecl {
name: "line_status".to_string(),
path: Some("/lines/{line_id}/status".to_string()),
method: Some("GET".to_string()),
parameters: vec![ParamDecl {
name: "line_id".to_string(),
param_type: Some("string".to_string()),
max_length: Some(floor + 1),
..Default::default()
}],
..Default::default()
},
ValidationSection::default(),
);
let findings = cfg.lint();
assert_eq!(findings.len(), 1, "got {findings:?}");
assert_eq!(findings[0].rule, DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP);
assert_eq!(findings[0].param.as_deref(), Some("line_id"));
assert!(
findings[0].detail.contains(&floor.to_string()),
"the finding must name the effective limit: {}",
findings[0].detail
);
let cfg = cfg_with_one_tool(
ToolDecl {
name: "line_status".to_string(),
path: Some("/lines/{line_id}/status".to_string()),
method: Some("GET".to_string()),
parameters: vec![ParamDecl {
name: "line_id".to_string(),
param_type: Some("string".to_string()),
max_length: Some(floor),
..Default::default()
}],
..Default::default()
},
ValidationSection::default(),
);
assert_eq!(cfg.lint(), Vec::new());
}
#[test]
fn lint_reports_every_active_opt_out() {
let cfg = cfg_with_one_tool(
ToolDecl {
name: "ping".to_string(),
sql: Some("SELECT 1".to_string()),
..Default::default()
},
ValidationSection {
enforce_input_schema: false,
default_max_length: 0,
additional_properties: true,
strict: false,
},
);
let rules: Vec<&str> = cfg.lint().iter().map(|w| w.rule).collect();
assert_eq!(
rules,
vec![
OPT_OUT_ENFORCE_INPUT_SCHEMA,
OPT_OUT_DEFAULT_MAX_LENGTH_ZERO,
OPT_OUT_ADDITIONAL_PROPERTIES,
]
);
for w in cfg.lint() {
assert!(w.tool.is_none(), "{w:?}");
assert!(w.param.is_none(), "{w:?}");
assert!(!w.to_string().is_empty(), "Display must render");
}
}
#[test]
fn validate_rejects_uncapped_string_param_in_strict_mode() {
let toml_body = r#"
[server]
name = "demo"
version = "0.1.0"
[[tools]]
name = "search_tracks"
sql = "SELECT 1"
[[tools.parameters]]
name = "q"
type = "string"
"#;
let lenient = ServerConfig::from_toml(toml_body).expect("parse");
lenient
.validate()
.expect("non-strict must never refuse to boot over an uncapped body string");
assert_eq!(lenient.lint().len(), 1);
let strict_toml = format!("{toml_body}\n[server.validation]\nstrict = true\n");
let strict = ServerConfig::from_toml(&strict_toml).expect("parse");
match strict.validate() {
Err(ConfigValidationError::UncappedStringParam {
ref tool,
ref param,
}) => {
assert_eq!(tool, "search_tracks");
assert_eq!(param, "q");
},
other => panic!("expected UncappedStringParam, got {other:?}"),
}
}
#[test]
fn validation_report_carries_the_effective_policy_and_per_tool_rules() {
let cfg = cfg_with_one_tool(
ToolDecl {
name: "line_status".to_string(),
path: Some("/lines/{line_id}/status".to_string()),
method: Some("GET".to_string()),
parameters: vec![ParamDecl {
name: "line_id".to_string(),
param_type: Some("string".to_string()),
required: true,
pattern: Some("^[0-9a-z-]+$".to_string()),
..Default::default()
}],
..Default::default()
},
ValidationSection::default(),
);
let report = cfg.validation_report();
assert!(report.enforce_input_schema);
assert_eq!(report.default_max_length, 256);
assert!(report.opt_outs.is_empty(), "nothing is opted out");
assert_eq!(report.tools.len(), 1);
assert_eq!(report.tools[0].tool, "line_status");
let rule = &report.tools[0].rules[0];
assert!(rule.contains("line_id"), "{rule}");
assert!(rule.contains("Path"), "{rule}");
assert!(rule.contains("pattern"), "{rule}");
assert!(rule.contains("maxLength=256 (default)"), "{rule}");
}
fn single_call_on(path: &str) -> ServerConfig {
cfg_with_one_tool(
ToolDecl {
name: "t".to_string(),
description: Some("t".to_string()),
path: Some(path.to_string()),
method: Some("GET".to_string()),
..Default::default()
},
ValidationSection::default(),
)
}
fn assert_malformed_segment(path: &str) {
let err = single_call_on(path)
.validate()
.expect_err("an unsupported path-template segment must be refused at config time");
match err {
ConfigValidationError::MalformedPathTemplateSegment { tool, segment } => {
assert_eq!(tool, "t");
assert!(!segment.is_empty(), "the finding must name the segment");
},
other => panic!("expected MalformedPathTemplateSegment, got {other:?}"),
}
}
#[test]
fn validate_rejects_a_path_template_segment_with_two_brace_pairs() {
assert_malformed_segment("/search/{a}{b}");
}
#[test]
fn validate_rejects_a_path_template_segment_with_text_adjacent_to_a_brace_pair() {
assert_malformed_segment("/prefix-{id}");
}
#[test]
fn validate_rejects_an_empty_path_template_placeholder() {
assert_malformed_segment("/a/{}/b");
}
#[test]
fn validate_rejects_an_unbalanced_path_template_brace() {
assert_malformed_segment("/a/{id");
}
#[test]
fn validate_accepts_whole_segment_path_template_placeholders() {
single_call_on("/content/{version}/CUI/{cui}")
.validate()
.expect("whole-segment placeholders are the supported shape");
}
#[test]
fn validate_accepts_a_path_template_carrying_an_author_written_query_string() {
single_call_on("/content/{version}/CUI?string=x")
.validate()
.expect("an author-written query string in a curated path must be accepted");
}
#[test]
fn validate_ignores_the_template_rule_for_a_tool_with_no_path() {
cfg_with_one_tool(
ToolDecl {
name: "t".to_string(),
sql: Some("SELECT 1".to_string()),
..Default::default()
},
ValidationSection::default(),
)
.validate()
.expect("a SQL tool carries no path template");
}
proptest! {
#[test]
fn server_config_minimal_round_trips(
name in "[a-zA-Z0-9_-]{1,32}",
version in "[0-9]+\\.[0-9]+\\.[0-9]+",
) {
let cfg = ServerConfig {
server: ServerSection {
name: name.clone(),
version: version.clone(),
..Default::default()
},
..Default::default()
};
let s = toml::to_string(&cfg).unwrap();
let parsed = ServerConfig::from_toml(&s).unwrap();
prop_assert_eq!(parsed.server.name, name);
prop_assert_eq!(parsed.server.version, version);
}
}
}
#[cfg(all(test, feature = "http"))]
mod lint_against_spec_tests {
use super::{ServerConfig, ToolDecl, CONFIGURED_TEMPLATE_NOT_IN_SPEC};
use crate::http::OpenApiSchema;
const SPEC: &str = r#"{
"openapi": "3.0.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/content/{version}/CUI": {
"get": {
"operationId": "getCui",
"parameters": [
{ "name": "version", "in": "path", "required": true,
"schema": { "type": "string", "pattern": "^[a-z]+$" } }
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"#;
fn spec() -> OpenApiSchema {
OpenApiSchema::parse(SPEC).expect("the fixture spec parses")
}
fn cfg_with(tools: Vec<ToolDecl>) -> ServerConfig {
ServerConfig {
server: super::ServerSection {
name: "t".to_string(),
version: "0.1.0".to_string(),
..Default::default()
},
tools,
..Default::default()
}
}
fn http_tool(name: &str, method: &str, path: &str) -> ToolDecl {
ToolDecl {
name: name.to_string(),
method: Some(method.to_string()),
path: Some(path.to_string()),
..Default::default()
}
}
#[test]
fn lint_against_spec_reports_a_template_the_spec_does_not_declare() {
let cfg = cfg_with(vec![http_tool("get_cui", "GET", "/content/{alias}/CUI")]);
let findings = cfg.lint_against_spec(&spec());
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].rule, CONFIGURED_TEMPLATE_NOT_IN_SPEC);
assert_eq!(findings[0].tool.as_deref(), Some("get_cui"));
assert!(
findings[0].detail.contains("floor"),
"the finding must say what a miss RETAINS, not only what it loses: {}",
findings[0].detail
);
}
#[test]
fn lint_against_spec_reports_a_method_the_spec_does_not_declare() {
let cfg = cfg_with(vec![http_tool(
"del_cui",
"DELETE",
"/content/{version}/CUI",
)]);
let rules: Vec<&str> = cfg
.lint_against_spec(&spec())
.iter()
.map(|w| w.rule)
.collect();
assert_eq!(rules, vec![CONFIGURED_TEMPLATE_NOT_IN_SPEC]);
}
#[test]
fn lint_against_spec_accepts_an_exactly_declared_template() {
let cfg = cfg_with(vec![
http_tool("a", "GET", "/content/{version}/CUI"),
http_tool("b", "get", "/content/{version}/CUI"),
]);
assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
}
#[test]
fn lint_against_spec_accepts_an_author_written_query_string() {
let cfg = cfg_with(vec![http_tool(
"a",
"GET",
"/content/{version}/CUI?string=x",
)]);
assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
}
#[test]
fn lint_against_spec_skips_a_tool_with_no_method_path_pair() {
let cfg = cfg_with(vec![ToolDecl {
name: "q".to_string(),
sql: Some("SELECT 1".to_string()),
..Default::default()
}]);
assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
}
}