Skip to content

Commit d504231

Browse files
authored
feat(agent): adds mcp json backwards compatibility (#2292)
* adds alternative format for mcp server field in agent config * adjusts migration flow for mcp backwards compatibility * fixes typo * implements @Native * WIP: utilizes type check to ensure invariant * makes agent generic to prevent api misuse * allows optional @Builtin prefix * reverts typestate pattern * assign agent name to agent during thaw * adds comment for freeze and thaw * monomorphisizes mcp server field in agent config * modifies migration flow to use database for migration status * updates agent doc comments * fixes errors from merge * makes freeze and thaw private
1 parent 1251e9c commit d504231

File tree

10 files changed

+329
-152
lines changed

10 files changed

+329
-152
lines changed

crates/chat-cli/src/cli/agent/legacy/mod.rs

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ use crate::os::Os;
2121
use crate::util::directories;
2222

2323
pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
24+
let has_migrated = os.database.get_has_migrated()?;
25+
if has_migrated.is_some_and(|has_migrated| has_migrated) {
26+
bail!("Nothing to migrate");
27+
}
28+
2429
let legacy_global_context_path = directories::chat_global_context_path(os)?;
2530
let legacy_global_context: Option<LegacyContextConfig> = 'global: {
2631
let Ok(content) = os.fs.read(&legacy_global_context_path).await else {
@@ -70,7 +75,12 @@ pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
7075
let config_path = directories::chat_legacy_mcp_config(os)?;
7176
if os.fs.exists(&config_path) {
7277
match McpServerConfig::load_from_file(os, config_path).await {
73-
Ok(config) => Some(config),
78+
Ok(mut config) => {
79+
config.mcp_servers.iter_mut().for_each(|(_name, config)| {
80+
config.is_from_legacy_mcp_json = true;
81+
});
82+
Some(config)
83+
},
7484
Err(e) => {
7585
error!("Malformed legacy global mcp config detected: {e}. Skipping mcp migration.");
7686
None
@@ -82,6 +92,7 @@ pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
8292
};
8393

8494
if legacy_global_context.is_none() && legacy_profiles.is_empty() {
95+
os.database.set_has_migrated()?;
8596
bail!("Nothing to migrate");
8697
}
8798

@@ -113,8 +124,6 @@ pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
113124
const DEFAULT_DESC: &str = "This is an agent migrated from global context";
114125
const PROFILE_DESC: &str = "This is an agent migrated from profile context";
115126

116-
let has_global_context = legacy_global_context.is_some();
117-
118127
// Migration of global context
119128
let mut new_agents = vec![];
120129
if let Some(context) = legacy_global_context {
@@ -192,20 +201,8 @@ pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
192201
os.fs.create_dir_all(&global_agent_path).await?;
193202
}
194203

195-
let formatted_server_list = mcp_servers
196-
.map(|config| {
197-
config
198-
.mcp_servers
199-
.keys()
200-
.map(|server_name| format!("@{server_name}"))
201-
.collect::<Vec<_>>()
202-
})
203-
.unwrap_or_default();
204-
205204
for agent in &mut new_agents {
206-
agent.tools.extend(formatted_server_list.clone());
207-
208-
let content = serde_json::to_string_pretty(agent)?;
205+
let content = agent.to_str_pretty()?;
209206
if let Some(path) = agent.path.as_ref() {
210207
info!("Agent {} peristed in path {}", agent.name, path.to_string_lossy());
211208
os.fs.write(path, content).await?;
@@ -217,27 +214,7 @@ pub async fn migrate(os: &mut Os) -> eyre::Result<Vec<Agent>> {
217214
}
218215
}
219216

220-
let legacy_profile_config_path = directories::chat_profiles_dir(os)?;
221-
let profile_backup_path = legacy_profile_config_path
222-
.parent()
223-
.ok_or(eyre::eyre!("Failed to obtain profile config parent path"))?
224-
.join("profiles.bak");
225-
os.fs.rename(legacy_profile_config_path, profile_backup_path).await?;
226-
227-
if has_global_context {
228-
let legacy_global_config_path = directories::chat_global_context_path(os)?;
229-
let legacy_global_config_file_name = legacy_global_config_path
230-
.file_name()
231-
.ok_or(eyre::eyre!("Failed to obtain legacy global config name"))?
232-
.to_string_lossy();
233-
let global_context_backup_path = legacy_global_config_path
234-
.parent()
235-
.ok_or(eyre::eyre!("Failed to obtain parent path for global context"))?
236-
.join(format!("{}.bak", legacy_global_config_file_name));
237-
os.fs
238-
.rename(legacy_global_config_path, global_context_backup_path)
239-
.await?;
240-
}
217+
os.database.set_has_migrated()?;
241218

242219
Ok(new_agents)
243220
}

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 146 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,30 @@ use crate::util::{
6666
/// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is
6767
/// impacting q chat in via influenicng [ContextManager] and [ToolManager].
6868
/// Changes made to [ContextManager] and [ToolManager] do not persist across sessions.
69+
///
70+
/// To increase the usability of the agent config, (both from the perspective of CLI and the users
71+
/// who would need to write these config), the agent config has two states of existence: "cold" and
72+
/// "warm".
73+
///
74+
/// A "cold" state describes the config as it is written. And a "warm" state is an alternate form
75+
/// of the same config, modified for the convenience of the business logic that relies on it in the
76+
/// application.
77+
///
78+
/// For example, the "cold" state does not require the field of "path" to be populated. This is
79+
/// because it would be redundant and tedious for user to have to write the path of the file they
80+
/// had created in said file. This field is thus populated during its parsing.
81+
///
82+
/// Another example is the mcp config. To support backwards compatibility of users existing global
83+
/// mcp.json, we allow users to supply a flag to denote whether they would want to include servers
84+
/// from the legacy global mcp.json. If this flag exists, we would need to read the legacy mcp
85+
/// config and merge it with what is in the agent mcp servers field. Conversely, when we write this
86+
/// config to file, we would want to filter out the servers that belong only in the mcp.json.
87+
///
88+
/// Where agents are instantiated from their config, we would need to convert them from "cold" to
89+
/// "warm".
6990
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema)]
7091
#[serde(rename_all = "camelCase")]
92+
#[schemars(description = "An Agent is a declarative way of configuring a given instance of q chat.")]
7193
pub struct Agent {
7294
/// Agent names are derived from the file name. Thus they are skipped for
7395
/// serializing
@@ -105,6 +127,11 @@ pub struct Agent {
105127
#[serde(default)]
106128
#[schemars(schema_with = "tool_settings_schema")]
107129
pub tools_settings: HashMap<ToolSettingTarget, serde_json::Value>,
130+
/// Whether or not to include the legacy ~/.aws/amazonq/mcp.json in the agent
131+
/// You can reference tools brought in by these servers as just as you would with the servers
132+
/// you configure in the mcpServers field in this config
133+
#[serde(default)]
134+
pub use_legacy_mcp_json: bool,
108135
#[serde(skip)]
109136
pub path: Option<PathBuf>,
110137
}
@@ -116,7 +143,7 @@ impl Default for Agent {
116143
description: Some("Default agent".to_string()),
117144
prompt: Default::default(),
118145
mcp_servers: Default::default(),
119-
tools: NATIVE_TOOLS.iter().copied().map(str::to_string).collect::<Vec<_>>(),
146+
tools: vec!["*".to_string()],
120147
tool_aliases: Default::default(),
121148
allowed_tools: {
122149
let mut set = HashSet::<String>::new();
@@ -130,20 +157,83 @@ impl Default for Agent {
130157
.collect::<Vec<_>>(),
131158
hooks: Default::default(),
132159
tools_settings: Default::default(),
160+
use_legacy_mcp_json: true,
133161
path: None,
134162
}
135163
}
136164
}
137165

138166
impl Agent {
167+
/// This function mutates the agent to a state that is writable.
168+
/// Practically this means reverting some fields back to their original values as they were
169+
/// written in the config.
170+
fn freeze(&mut self) -> eyre::Result<()> {
171+
let Self { mcp_servers, .. } = self;
172+
173+
mcp_servers
174+
.mcp_servers
175+
.retain(|_name, config| !config.is_from_legacy_mcp_json);
176+
177+
Ok(())
178+
}
179+
180+
/// This function mutates the agent to a state that is usable for runtime.
181+
/// Practically this means to convert some of the fields value to their usable counterpart.
182+
/// For example, we populate the agent with its file name, convert the mcp array to actual
183+
/// mcp config and populate the agent file path.
184+
fn thaw(&mut self, path: &Path, global_mcp_config: Option<&McpServerConfig>) -> eyre::Result<()> {
185+
let Self { mcp_servers, .. } = self;
186+
187+
let name = path
188+
.file_stem()
189+
.ok_or(eyre::eyre!("Missing valid file name"))?
190+
.to_string_lossy()
191+
.to_string();
192+
193+
self.name = name.clone();
194+
195+
if let (true, Some(global_mcp_config)) = (self.use_legacy_mcp_json, global_mcp_config) {
196+
let mut stderr = std::io::stderr();
197+
for (name, legacy_server) in &global_mcp_config.mcp_servers {
198+
if mcp_servers.mcp_servers.contains_key(name) {
199+
let _ = queue!(
200+
stderr,
201+
style::SetForegroundColor(Color::Yellow),
202+
style::Print("WARNING: "),
203+
style::ResetColor,
204+
style::Print("MCP server '"),
205+
style::SetForegroundColor(Color::Green),
206+
style::Print(name),
207+
style::ResetColor,
208+
style::Print(
209+
"' is already configured in agent config. Skipping duplicate from legacy mcp.json.\n"
210+
)
211+
);
212+
continue;
213+
}
214+
let mut server_clone = legacy_server.clone();
215+
server_clone.is_from_legacy_mcp_json = true;
216+
mcp_servers.mcp_servers.insert(name.clone(), server_clone);
217+
}
218+
}
219+
220+
Ok(())
221+
}
222+
223+
pub fn to_str_pretty(&self) -> eyre::Result<String> {
224+
let mut agent_clone = self.clone();
225+
agent_clone.freeze()?;
226+
Ok(serde_json::to_string_pretty(&agent_clone)?)
227+
}
228+
139229
/// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
140230
/// and falling back to global dir if it does not exist in local.
141231
pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> {
142232
let config_path: Result<PathBuf, PathBuf> = 'config: {
143233
// local first, and then fall back to looking at global
144-
let local_config_dir = directories::chat_local_agent_dir()?.join(agent_name);
234+
let local_config_dir = directories::chat_local_agent_dir()?.join(format!("{agent_name}.json"));
145235
if os.fs.exists(&local_config_dir) {
146-
break 'config Ok::<PathBuf, PathBuf>(local_config_dir);
236+
break 'config Ok(local_config_dir);
147237
}
148238

149239
let global_config_dir = directories::chat_global_agent_path(os)?.join(format!("{agent_name}.json"));
@@ -157,23 +247,18 @@ impl Agent {
157247
match config_path {
158248
Ok(config_path) => {
159249
let content = os.fs.read(&config_path).await?;
160-
Ok((serde_json::from_slice::<Agent>(&content)?, config_path))
161-
},
162-
Err(global_config_dir) if agent_name == "default" => {
163-
os.fs
164-
.create_dir_all(
165-
global_config_dir
166-
.parent()
167-
.ok_or(eyre::eyre!("Failed to retrieve global agent config parent path"))?,
168-
)
169-
.await?;
170-
os.fs.create_new(&global_config_dir).await?;
171-
172-
let default_agent = Agent::default();
173-
let content = serde_json::to_string_pretty(&default_agent)?;
174-
os.fs.write(&global_config_dir, content.as_bytes()).await?;
175-
176-
Ok((default_agent, global_config_dir))
250+
let mut agent = serde_json::from_slice::<Agent>(&content)?;
251+
252+
let global_mcp_path = directories::chat_legacy_mcp_config(os)?;
253+
let global_mcp_config = match McpServerConfig::load_from_file(os, global_mcp_path).await {
254+
Ok(config) => Some(config),
255+
Err(e) => {
256+
tracing::error!("Error loading global mcp json path: {e}.");
257+
None
258+
},
259+
};
260+
agent.thaw(&config_path, global_mcp_config.as_ref())?;
261+
Ok((agent, config_path))
177262
},
178263
_ => bail!("Agent {agent_name} does not exist"),
179264
}
@@ -252,7 +337,8 @@ impl Agents {
252337
path: Some(agent_path.clone()),
253338
..Default::default()
254339
};
255-
let contents = serde_json::to_string_pretty(&agent)
340+
let contents = agent
341+
.to_str_pretty()
256342
.map_err(|e| eyre::eyre!("Failed to serialize profile configuration: {}", e))?;
257343

258344
if let Some(parent) = agent_path.parent() {
@@ -307,6 +393,8 @@ impl Agents {
307393
vec![]
308394
};
309395

396+
let mut global_mcp_config = None::<McpServerConfig>;
397+
310398
let mut local_agents = 'local: {
311399
// We could be launching from the home dir, in which case the global and local agents
312400
// are the same set of agents. If that is the case, we simply skip this.
@@ -324,7 +412,7 @@ impl Agents {
324412
let Ok(files) = os.fs.read_dir(path).await else {
325413
break 'local Vec::<Agent>::new();
326414
};
327-
load_agents_from_entries(files).await
415+
load_agents_from_entries(files, os, &mut global_mcp_config).await
328416
};
329417

330418
let mut global_agents = 'global: {
@@ -342,7 +430,7 @@ impl Agents {
342430
break 'global Vec::<Agent>::new();
343431
},
344432
};
345-
load_agents_from_entries(files).await
433+
load_agents_from_entries(files, os, &mut global_mcp_config).await
346434
}
347435
.into_iter()
348436
.chain(new_agents)
@@ -385,7 +473,7 @@ impl Agents {
385473
},
386474
..Default::default()
387475
};
388-
let Ok(content) = serde_json::to_string_pretty(&example_agent) else {
476+
let Ok(content) = example_agent.to_str_pretty() else {
389477
error!("Error serializing example agent config");
390478
break 'example_config;
391479
};
@@ -522,8 +610,13 @@ impl Agents {
522610
}
523611
}
524612

525-
async fn load_agents_from_entries(mut files: ReadDir) -> Vec<Agent> {
613+
async fn load_agents_from_entries(
614+
mut files: ReadDir,
615+
os: &Os,
616+
global_mcp_config: &mut Option<McpServerConfig>,
617+
) -> Vec<Agent> {
526618
let mut res = Vec::<Agent>::new();
619+
527620
while let Ok(Some(file)) = files.next_entry().await {
528621
let file_path = &file.path();
529622
if file_path
@@ -539,6 +632,7 @@ async fn load_agents_from_entries(mut files: ReadDir) -> Vec<Agent> {
539632
continue;
540633
},
541634
};
635+
542636
let mut agent = match serde_json::from_slice::<Agent>(&content) {
543637
Ok(mut agent) => {
544638
agent.path = Some(file_path.clone());
@@ -550,13 +644,34 @@ async fn load_agents_from_entries(mut files: ReadDir) -> Vec<Agent> {
550644
continue;
551645
},
552646
};
553-
if let Some(name) = Path::new(&file.file_name()).file_stem() {
554-
agent.name = name.to_string_lossy().to_string();
555-
res.push(agent);
556-
} else {
557-
let file_path = file_path.to_string_lossy();
558-
tracing::error!("Unable to determine agent name from config file at {file_path}, skipping");
647+
648+
// The agent config could have use_legacy_mcp_json set to true but not have a valid
649+
// global mcp.json. We would still need to carry on loading the config.
650+
'load_legacy_mcp_json: {
651+
if agent.use_legacy_mcp_json && global_mcp_config.is_none() {
652+
let Ok(global_mcp_path) = directories::chat_legacy_mcp_config(os) else {
653+
tracing::error!("Error obtaining legacy mcp json path. Skipping");
654+
break 'load_legacy_mcp_json;
655+
};
656+
let legacy_mcp_config = match McpServerConfig::load_from_file(os, global_mcp_path).await {
657+
Ok(config) => config,
658+
Err(e) => {
659+
tracing::error!("Error loading global mcp json path: {e}. Skipping");
660+
break 'load_legacy_mcp_json;
661+
},
662+
};
663+
global_mcp_config.replace(legacy_mcp_config);
664+
}
559665
}
666+
667+
if let Err(e) = agent.thaw(file_path, global_mcp_config.as_ref()) {
668+
tracing::error!(
669+
"Error transforming agent at {} to usable state: {e}. Skipping",
670+
file_path.display()
671+
);
672+
};
673+
674+
res.push(agent);
560675
}
561676
}
562677
res
@@ -806,11 +921,4 @@ mod tests {
806921
assert!(validate_agent_name("invalid!").is_err());
807922
assert!(validate_agent_name("invalid space").is_err());
808923
}
809-
810-
#[test]
811-
fn test_schema_gen() {
812-
use schemars::schema_for;
813-
let schema = schema_for!(Agent);
814-
println!("Schema for agent: {}", serde_json::to_string_pretty(&schema).unwrap());
815-
}
816924
}

crates/chat-cli/src/cli/agent/root_command_args.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ pub async fn create_agent(
163163

164164
let prepopulated_content = if let Some(from) = from {
165165
let agent_to_copy = agents.switch(from.as_str())?;
166-
serde_json::to_string_pretty(agent_to_copy)?
166+
agent_to_copy.to_str_pretty()?
167167
} else {
168168
Default::default()
169169
};

0 commit comments

Comments
 (0)