use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use rmcp::model::{JsonObject, Tool};
use serde_json::{Map, Value, json};
use crate::config::Options;
use crate::constants::{
AGENT_ID_PARAM, AGENT_ID_PARAM_DESCRIPTION, GET_MORE_TOOLS_NAME,
MCP_SESSION_AGENT_ID_DESCRIPTION, MCP_SESSION_FIELD_DESCRIPTION,
MCP_SESSION_FIELD_DESCRIPTION_HOOK_MODE, MCP_SESSION_KEY, MCP_SESSION_SESSION_ID_DESCRIPTION,
MCP_SESSION_STATUS_DESCRIPTION, SESSION_ID_PARAM, SESSION_ID_PARAM_DESCRIPTION,
SESSION_ID_PARAM_PATTERN, get_more_tools_descriptor,
};
use crate::wire::{self, truthy};
#[cfg(test)]
mod tests;
pub(crate) const CONTEXT_PARAM: &str = "context";
const UNKNOWN_TOOL: &str = "unknown";
const COMPOSITION_KEYS: [&str; 3] = ["oneOf", "allOf", "anyOf"];
const SHADOW_WARNING: &str = "WARN: This server registers its own \"get_more_tools\" tool, which is shadowed by AgentCat's report-missing tool: calls to it are answered by AgentCat and never reach your handler. Rename your tool, or set Options::enable_report_missing(false) to keep yours reachable.";
#[derive(Debug, Clone, PartialEq, Eq)]
#[expect(
clippy::struct_excessive_bools,
reason = "the four switches of the pipeline"
)]
pub(crate) struct InjectConfig {
pub(crate) report_missing: bool,
pub(crate) inject_session_id: bool,
pub(crate) inject_agent_id: bool,
pub(crate) inject_context: bool,
pub(crate) context_description: String,
}
impl InjectConfig {
pub(crate) fn new(options: &Options, hook_mode: bool) -> Self {
let tracing = options.tracing();
Self {
report_missing: options.report_missing(),
inject_session_id: tracing && !hook_mode,
inject_agent_id: tracing && options.agent_tracking(),
inject_context: options.tool_call_context(),
context_description: options.context_description().to_owned(),
}
}
pub(crate) fn from_options(options: &Options) -> Self {
Self::new(options, options.hook_mode())
}
fn injects_handles(&self) -> bool {
self.inject_session_id || self.inject_agent_id
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ToolEntry {
pub(crate) injected: Vec<&'static str>,
pub(crate) output_injected: bool,
pub(crate) declares_session: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct InjectState {
pub(crate) declared_session_params: BTreeSet<String>,
pub(crate) reported_conflicts: BTreeSet<String>,
pub(crate) shadow_reported: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct InjectedList {
pub(crate) tools: Vec<Tool>,
pub(crate) entries: Vec<(String, ToolEntry)>,
pub(crate) logs: Vec<String>,
}
pub(crate) fn build_injected_list(
config: &InjectConfig,
mut tools: Vec<Tool>,
state: &mut InjectState,
) -> InjectedList {
let mut logs = Vec::new();
if config.report_missing {
if tools.iter().any(|tool| tool.name == GET_MORE_TOOLS_NAME) {
if !state.shadow_reported {
state.shadow_reported = true;
logs.push(SHADOW_WARNING.to_owned());
}
} else {
tools.push(get_more_tools_tool());
}
}
let mut registry = PageRegistry::default();
if config.injects_handles() {
let mut injected = Vec::with_capacity(tools.len());
for tool in tools {
let key = registry_name(&tool).to_owned();
let (tool, step) = inject_handles(config, tool, state, &mut logs);
registry.handles(key, step);
injected.push(tool);
}
tools = injected;
}
if config.inject_context {
let mut injected = Vec::with_capacity(tools.len());
for tool in tools {
if tool.name == GET_MORE_TOOLS_NAME {
injected.push(tool);
continue;
}
let key = registry_name(&tool).to_owned();
let (tool, step) = inject_context(config, tool, &mut logs);
registry.context(key, step);
injected.push(tool);
}
tools = injected;
}
for tool in &tools {
if !tool.name.is_empty() {
registry.params.entry(tool.name.to_string()).or_default();
}
}
InjectedList {
tools,
entries: registry.into_entries(),
logs,
}
}
pub(crate) fn inject_tool(
config: &InjectConfig,
tool: Tool,
state: &mut InjectState,
logs: &mut Vec<String>,
) -> (Tool, ToolEntry) {
let (mut tool, mut entry) = if config.injects_handles() {
let (tool, step) = inject_handles(config, tool, state, logs);
(tool, step.entry)
} else {
(tool, ToolEntry::default())
};
if config.inject_context && tool.name != GET_MORE_TOOLS_NAME {
let (injected, step) = inject_context(config, tool, logs);
if step == ContextStep::Injected {
entry.injected.push(CONTEXT_PARAM);
}
tool = injected;
}
(tool, entry)
}
pub(crate) fn get_more_tools_tool() -> Tool {
serde_json::from_value(get_more_tools_descriptor()).unwrap_or_default()
}
#[derive(Default)]
struct PageRegistry {
params: BTreeMap<String, Vec<&'static str>>,
output: BTreeSet<String>,
declares_session: BTreeSet<String>,
}
impl PageRegistry {
fn handles(&mut self, key: String, step: HandleStep) {
if step.entry.declares_session {
self.declares_session.insert(key.clone());
}
if step.failed {
self.params.remove(&key);
self.output.remove(&key);
return;
}
if step.entry.output_injected {
self.output.insert(key.clone());
}
if !step.entry.injected.is_empty() {
let params = self.params.entry(key).or_default();
for param in step.entry.injected {
if !params.contains(¶m) {
params.push(param);
}
}
}
}
fn context(&mut self, key: String, step: ContextStep) {
match step {
ContextStep::Untouched => {}
ContextStep::Injected => {
let params = self.params.entry(key).or_default();
if !params.contains(&CONTEXT_PARAM) {
params.push(CONTEXT_PARAM);
}
}
ContextStep::Failed => {
if let Some(params) = self.params.get_mut(&key) {
params.retain(|param| *param != CONTEXT_PARAM);
}
}
}
}
fn into_entries(self) -> Vec<(String, ToolEntry)> {
let Self {
params,
output,
declares_session,
} = self;
params
.into_iter()
.map(|(name, injected)| {
let entry = ToolEntry {
injected,
output_injected: output.contains(&name),
declares_session: declares_session.contains(&name),
};
(name, entry)
})
.collect()
}
}
struct HandleStep {
entry: ToolEntry,
failed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContextStep {
Untouched,
Injected,
Failed,
}
fn registry_name(tool: &Tool) -> &str {
if tool.name.is_empty() {
UNKNOWN_TOOL
} else {
&tool.name
}
}
fn inject_handles(
config: &InjectConfig,
tool: Tool,
state: &mut InjectState,
logs: &mut Vec<String>,
) -> (Tool, HandleStep) {
let key = registry_name(&tool).to_owned();
let mut entry = ToolEntry::default();
if is_composed(&tool.input_schema) {
if config.inject_session_id && declares(&tool.input_schema, SESSION_ID_PARAM) {
state.declared_session_params.insert(key.clone());
entry.declares_session = true;
}
logs.push(format!(
"WARN: Tool \"{key}\" has complex schema (oneOf/allOf/anyOf). Skipping handle injection."
));
return (
tool,
HandleStep {
entry,
failed: false,
},
);
}
let mut injected = tool.clone();
match add_handles(config, &mut injected, &key, &mut entry, state, logs) {
Ok(()) => (
injected,
HandleStep {
entry,
failed: false,
},
),
Err(error) => {
logs.push(format!(
"WARN: Handle injection failed for tool \"{key}\"; listing it unmodified - {error}"
));
let entry = ToolEntry {
declares_session: entry.declares_session,
..ToolEntry::default()
};
(
tool,
HandleStep {
entry,
failed: true,
},
)
}
}
}
fn add_handles(
config: &InjectConfig,
tool: &mut Tool,
key: &str,
entry: &mut ToolEntry,
state: &mut InjectState,
logs: &mut Vec<String>,
) -> Result<(), String> {
let schema = Arc::make_mut(&mut tool.input_schema);
ensure_properties(schema);
drop_additional_false(schema);
if config.inject_session_id {
if declares(schema, SESSION_ID_PARAM) {
entry.declares_session = true;
state.declared_session_params.insert(key.to_owned());
if state.reported_conflicts.insert(key.to_owned()) {
logs.push(format!(
"ERROR: Tool \"{key}\" already declares a '{SESSION_ID_PARAM}' parameter. AgentCat will not inject its own, and calls to this tool are published without a session, so they cannot be correlated. Your parameter is untouched and still reaches your handler. If you already manage sessions, set an Options::resolve_session_id hook \u{2014} AgentCat will derive its session from your identifier and stop injecting {SESSION_ID_PARAM} entirely."
));
}
} else {
let property = json!({
"type": "string",
"description": SESSION_ID_PARAM_DESCRIPTION,
"pattern": SESSION_ID_PARAM_PATTERN,
});
set_property(ensure_properties(schema), SESSION_ID_PARAM, property)?;
entry.injected.push(SESSION_ID_PARAM);
add_to_required(schema, SESSION_ID_PARAM);
}
}
if config.inject_agent_id {
if declares(schema, AGENT_ID_PARAM) {
logs.push(format!(
"WARN: Tool \"{key}\" already has '{AGENT_ID_PARAM}' parameter. Skipping agent_id injection."
));
} else {
let property = json!({"type": "string", "description": AGENT_ID_PARAM_DESCRIPTION});
set_property(ensure_properties(schema), AGENT_ID_PARAM, property)?;
entry.injected.push(AGENT_ID_PARAM);
add_to_required(schema, AGENT_ID_PARAM);
}
}
if let Some(output) = &mut tool.output_schema {
entry.output_injected = add_mcp_session(config, output, key, logs)?;
}
Ok(())
}
fn add_mcp_session(
config: &InjectConfig,
output: &mut Arc<JsonObject>,
key: &str,
logs: &mut Vec<String>,
) -> Result<bool, String> {
if is_composed(output) {
logs.push(format!(
"WARN: Tool \"{key}\" has complex outputSchema (oneOf/allOf/anyOf). Skipping {MCP_SESSION_KEY} injection; mint-back stays content-only for this tool."
));
return Ok(false);
}
if declares(output, MCP_SESSION_KEY) {
logs.push(format!(
"WARN: Tool \"{key}\" already declares '{MCP_SESSION_KEY}' in outputSchema. Skipping injection."
));
return Ok(false);
}
let mut properties = Map::new();
if config.inject_session_id {
properties.insert(
SESSION_ID_PARAM.to_owned(),
json!({"type": "string", "description": MCP_SESSION_SESSION_ID_DESCRIPTION}),
);
}
if config.inject_agent_id {
properties.insert(
AGENT_ID_PARAM.to_owned(),
json!({"type": "string", "description": MCP_SESSION_AGENT_ID_DESCRIPTION}),
);
}
if config.inject_session_id {
properties.insert(
"status".to_owned(),
json!({
"type": "string",
"enum": ["issued", "active", "unrecognized"],
"description": MCP_SESSION_STATUS_DESCRIPTION,
}),
);
}
let description = if config.inject_session_id {
MCP_SESSION_FIELD_DESCRIPTION
} else {
MCP_SESSION_FIELD_DESCRIPTION_HOOK_MODE
};
let property = json!({"type": "object", "description": description, "properties": properties});
set_property(
ensure_properties(Arc::make_mut(output)),
MCP_SESSION_KEY,
property,
)?;
Ok(true)
}
fn inject_context(
config: &InjectConfig,
tool: Tool,
logs: &mut Vec<String>,
) -> (Tool, ContextStep) {
let key = registry_name(&tool);
if declares(&tool.input_schema, CONTEXT_PARAM) {
logs.push(format!(
"WARN: Tool \"{key}\" already has '{CONTEXT_PARAM}' parameter. Skipping context injection."
));
return (tool, ContextStep::Untouched);
}
if is_composed(&tool.input_schema) {
logs.push(format!(
"WARN: Tool \"{key}\" has complex schema (oneOf/allOf/anyOf). Skipping context injection."
));
return (tool, ContextStep::Untouched);
}
let mut injected = tool.clone();
let schema = Arc::make_mut(&mut injected.input_schema);
ensure_properties(schema);
drop_additional_false(schema);
let property = json!({"type": "string", "description": config.context_description});
match set_property(ensure_properties(schema), CONTEXT_PARAM, property) {
Ok(()) => {
add_to_required(schema, CONTEXT_PARAM);
(injected, ContextStep::Injected)
}
Err(error) => {
logs.push(format!(
"WARN: Context injection failed for tool \"{key}\"; listing it unmodified - {error}"
));
(tool, ContextStep::Failed)
}
}
}
fn is_composed(schema: &JsonObject) -> bool {
COMPOSITION_KEYS
.iter()
.any(|key| schema.get(*key).is_some_and(truthy))
}
fn declares(schema: &JsonObject, name: &str) -> bool {
matches!(schema.get("properties"), Some(Value::Object(properties)) if properties.get(name).is_some_and(truthy))
}
fn ensure_properties(schema: &mut JsonObject) -> &mut Value {
let properties = schema
.entry("properties")
.or_insert_with(|| Value::Object(Map::new()));
if !truthy(properties) {
*properties = Value::Object(Map::new());
}
properties
}
fn drop_additional_false(schema: &mut JsonObject) {
if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
schema.shift_remove("additionalProperties");
}
}
fn set_property(properties: &mut Value, name: &str, value: Value) -> Result<(), String> {
match properties {
Value::Object(map) => {
map.insert(name.to_owned(), value);
Ok(())
}
Value::Array(_) => Ok(()),
other => Err(format!(
"cannot add property '{name}' to {} '{}'",
wire::type_of(Some(other)),
wire::coerce_to_string(Some(other)),
)),
}
}
fn add_to_required(schema: &mut JsonObject, param: &'static str) {
if let Some(Value::Array(required)) = schema.get_mut("required") {
if !required.iter().any(|item| item.as_str() == Some(param)) {
required.push(json!(param));
}
return;
}
schema.insert("required".to_owned(), json!([param]));
}