pub type Result<T> = std::result::Result<T, ToolkitError>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ToolkitError {
#[error("failed to parse config TOML: {0}")]
Parse(#[from] toml::de::Error),
#[error("missing required config field: {0}")]
MissingField(String),
#[error("tool synthesis failed: {0}")]
Synth(String),
#[error("code-mode wiring failed: {0}")]
CodeMode(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("secret '{name}' not resolvable: {cause}")]
Secret {
name: String,
cause: String,
},
#[error("config validation failed: {0}")]
Validation(#[from] ConfigValidationError),
#[error(
"[backend].base_url references environment variable '{var}', which is \
unset or empty (set it to the REST API root URL)"
)]
UnresolvedBaseUrlRef {
var: String,
},
#[cfg(feature = "workbook")]
#[error("workbook bundle load failed: {0}")]
Workbook(#[from] pmcp_workbook_runtime::BundleLoadError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigValidationError {
#[error("server.name must be non-empty")]
EmptyServerName,
#[error("server.version must be non-empty")]
EmptyServerVersion,
#[error("[[tools]] entry at index {0} has empty name")]
EmptyToolName(usize),
#[error("[[database.tables]] entry at index {0} has empty name")]
EmptyTableName(usize),
#[error(
"[code_mode].token_secret is an inline literal; use 'env:VAR_NAME' \
or set allow_inline_token_secret_for_dev=true (NEVER in production)"
)]
InlineSecretRejected,
#[error(
"[[tools]] entry at index {0} declares ambiguous tool kind: set exactly \
one of `sql`, `path`/`method`, or `script` (not a mixture)"
)]
AmbiguousToolKind(usize),
#[error(
"[backend].base_url must be non-empty (set the REST API root URL, \
e.g. \"https://api.example.com\")"
)]
EmptyBackendBaseUrl,
#[error(
"[backend].base_url is a malformed environment reference; a reference must be \
exactly one `${{VAR}}` or `env:VAR` naming a single variable — inline \
compositions like \"${{SCHEME}}://${{HOST}}\" cannot be resolved by any \
environment, so compose the full URL in ONE variable instead"
)]
MalformedBackendBaseUrlRef,
#[error(
"[backend.auth].{0} is a malformed environment reference; a reference must be \
exactly one `${{VAR}}` (name matching [A-Za-z0-9_]+) or `env:VAR` naming a single \
variable — no environment can satisfy this value, so the credential would be \
silently omitted and every backend request sent unauthenticated"
)]
MalformedBackendAuthRef(String),
#[error("[[config_slots]] entry at index {0} has an empty key or name")]
EmptyConfigSlotField(usize),
#[error(
"[[config_slots]] entry at index {0} is kind = \"secret\" but carries a tested_value; \
identity-bearing slots record no value — remove it (a credential must never sit in \
the config file)"
)]
SecretSlotCarriesTestedValue(usize),
#[error(
"[[tools]] '{tool}' has a declared parameter schema that does not compile at \
{position}: {detail}"
)]
UncompilableParamSchema {
tool: String,
position: String,
detail: String,
},
#[error(
"[[tools]] '{tool}' parameter '{param}' declares an empty pattern; an empty pattern \
matches every value and enforces nothing — remove the key or write the rule"
)]
EmptyParamPattern {
tool: String,
param: String,
},
#[error(
"[[tools]] '{tool}' parameter '{param}' declares a minimum/maximum that is \
non-finite or exceeds 2^53; bounds are stored as f64, so such a value cannot be \
represented exactly — bound a large integer ID with a `pattern` instead"
)]
NonFiniteParamBound {
tool: String,
param: String,
},
#[error(
"[[tools]] '{tool}' parameter '{param}' is an uncapped string — no declared \
max_length and no default cap reaches it — and [server.validation] strict = true; \
declare a max_length, or clear the strict flag (the paired lint finding names why \
no cap reached it)"
)]
UncappedStringParam {
tool: String,
param: String,
},
#[error(
"[[tools]] '{tool}' path template segment '{segment}' is not a supported \
placeholder shape: a segment either contains no braces at all, or is \
exactly one non-empty '{{name}}' spanning the whole segment"
)]
MalformedPathTemplateSegment {
tool: String,
segment: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigWarning {
pub tool: Option<String>,
pub param: Option<String>,
pub rule: &'static str,
pub detail: String,
}
impl std::fmt::Display for ConfigWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.tool.as_deref(), self.param.as_deref()) {
(None, _) => write!(f, "[{}] {}", self.rule, self.detail),
(Some(tool), None) => {
write!(f, "[{}] [[tools]] '{tool}': {}", self.rule, self.detail)
},
(Some(tool), Some(param)) => write!(
f,
"[{}] [[tools]] '{tool}' parameter '{param}': {}",
self.rule, self.detail
),
}
}
}
#[cfg(test)]
mod config_warning_display {
use super::ConfigWarning;
fn warning(tool: &str, param: &str) -> ConfigWarning {
let scope = |s: &str| (!s.is_empty()).then(|| s.to_string());
ConfigWarning {
tool: scope(tool),
param: scope(param),
rule: "a-rule",
detail: "a detail".to_string(),
}
}
#[test]
fn renders_server_tool_and_parameter_scopes_distinctly() {
assert_eq!(warning("", "").to_string(), "[a-rule] a detail");
assert_eq!(
warning("get_cui", "").to_string(),
"[a-rule] [[tools]] 'get_cui': a detail"
);
assert_eq!(
warning("get_cui", "version").to_string(),
"[a-rule] [[tools]] 'get_cui' parameter 'version': a detail"
);
}
#[test]
fn a_tool_level_finding_never_renders_an_empty_parameter_name() {
let rendered = warning("get_cui", "").to_string();
assert!(
!rendered.contains("parameter"),
"a tool-level finding must not claim a parameter: {rendered}"
);
assert!(
!rendered.contains("''"),
"no empty-name sentinel: {rendered}"
);
}
}