Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd1e59815
|
||
|
|
f5085a773a
|
||
|
|
09afdeaf7c
|
||
|
|
0216d84eee
|
||
|
|
320dbf2479
|
@@ -3,7 +3,7 @@ use crate::mcp::{
|
||||
spawn_mcp_server,
|
||||
};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -111,10 +111,10 @@ impl McpFactory {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_auth_required_error(&e) {
|
||||
anyhow!(
|
||||
e.context(format!(
|
||||
"MCP server '{name}' requires OAuth authentication. \
|
||||
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
|
||||
)
|
||||
))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
|
||||
+586
-11
@@ -1,3 +1,4 @@
|
||||
use super::agent::AgentConfig;
|
||||
use super::rag_cache::{RagCache, RagKey};
|
||||
use super::session::Session;
|
||||
use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||
@@ -20,7 +21,7 @@ use crate::function::{
|
||||
};
|
||||
use crate::mcp::{
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, is_auth_required_error,
|
||||
};
|
||||
use crate::rag::Rag;
|
||||
use crate::supervisor::Supervisor;
|
||||
@@ -87,6 +88,37 @@ pub fn should_inject_skill_instructions(app: &AppConfig, policy: &SkillPolicy) -
|
||||
app.function_calling_support && policy.skills_enabled && !policy.compatible_enabled.is_empty()
|
||||
}
|
||||
|
||||
fn print_asset_names(kind: &str, names: &[String]) -> Result<()> {
|
||||
if names.is_empty() {
|
||||
println!("No {kind} found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut header: Vec<char> = kind.chars().collect();
|
||||
header[0] = header[0].to_ascii_uppercase();
|
||||
let header: String = header.into_iter().collect();
|
||||
|
||||
println!("{header}:");
|
||||
for name in names {
|
||||
println!(" • {name}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<String>)> {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let description = Skill::load(&name)
|
||||
.ok()
|
||||
.map(|s| s.description().to_string())
|
||||
.filter(|d| !d.is_empty());
|
||||
(name, description)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RenderMode {
|
||||
#[default]
|
||||
@@ -999,6 +1031,273 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn concrete_tool_names(&self) -> Vec<String> {
|
||||
self.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
!v.name.starts_with("user__")
|
||||
&& !v.name.starts_with("mcp_")
|
||||
&& !v.name.starts_with("todo__")
|
||||
&& !v.name.starts_with("agent__")
|
||||
})
|
||||
.map(|v| v.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_list_covers(&self, list: &[String], name: &str) -> bool {
|
||||
for item in list {
|
||||
let item = item.trim();
|
||||
if item == name {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(values) = self.app.config.mapping_tools.get(item)
|
||||
&& values.split(',').any(|v| v.trim() == name)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn mcp_list_covers(&self, list: &[String], name: &str) -> bool {
|
||||
for item in list {
|
||||
let item = item.trim();
|
||||
if item == name {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(values) = self.app.config.mapping_mcp_servers.get(item)
|
||||
&& values.split(',').any(|v| v.trim() == name)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn write_layer_label(&self) -> &'static str {
|
||||
if self.session.is_some() {
|
||||
"session"
|
||||
} else if self.agent.is_some() {
|
||||
"agent"
|
||||
} else if self.role.is_some() {
|
||||
"role"
|
||||
} else {
|
||||
"global, in-memory"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_tool(&mut self, action: &str, name: &str) -> Result<()> {
|
||||
let name = name.trim();
|
||||
let enable = match action {
|
||||
"enable" => true,
|
||||
"disable" => false,
|
||||
_ => bail!("Unknown action '{action}'. Usage: .tool <enable|disable> <name>"),
|
||||
};
|
||||
|
||||
if self.session.is_none() && self.agent.is_some() {
|
||||
bail!(
|
||||
"Cannot adjust tools for an agent outside a session; start a session first with `.session`"
|
||||
);
|
||||
}
|
||||
|
||||
let pool = self.concrete_tool_names();
|
||||
let is_alias = self.app.config.mapping_tools.contains_key(name);
|
||||
if !pool.iter().any(|v| v == name) && !is_alias {
|
||||
bail!("Unknown tool '{name}'. Run `.list tools` to see what's available");
|
||||
}
|
||||
|
||||
let current: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_tools()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_tools()
|
||||
} else {
|
||||
self.app.config.enabled_tools.clone()
|
||||
};
|
||||
let layer = self.write_layer_label();
|
||||
|
||||
let new_list: Vec<String> = if enable {
|
||||
match current {
|
||||
Some(list) if list.iter().any(|s| s.trim() == "all") => {
|
||||
println!("Tool '{name}' is already enabled ('all' is set).");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if self.tool_list_covers(&list, name) => {
|
||||
println!("Tool '{name}' is already enabled.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(mut list) => {
|
||||
list.push(name.to_string());
|
||||
list
|
||||
}
|
||||
None => vec![name.to_string()],
|
||||
}
|
||||
} else {
|
||||
match current {
|
||||
None => {
|
||||
println!("No tools are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if list.is_empty() => {
|
||||
println!("No tools are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if list.iter().any(|s| s.trim() == "all") => {
|
||||
let mut materialized = pool;
|
||||
materialized.retain(|v| v != name);
|
||||
println!(
|
||||
"Note: expanded 'all' into {} concrete tools.",
|
||||
materialized.len()
|
||||
);
|
||||
materialized
|
||||
}
|
||||
Some(mut list) => {
|
||||
if list.iter().any(|s| s.trim() == name) {
|
||||
list.retain(|s| s.trim() != name);
|
||||
list
|
||||
} else if self.tool_list_covers(&list, name) {
|
||||
bail!(
|
||||
"Tool '{name}' is enabled via an alias in 'mapping_tools'. \
|
||||
Disable the alias instead, or set the list explicitly with `.set enabled_tools`."
|
||||
);
|
||||
} else {
|
||||
println!("Tool '{name}' is not enabled; nothing to do.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let new_list = Some(new_list);
|
||||
|
||||
if !self.set_enabled_tools_on_role_like(new_list.clone()) {
|
||||
self.update_app_config(|app| app.enabled_tools = new_list.clone());
|
||||
}
|
||||
|
||||
let verb = if enable { "Enabled" } else { "Disabled" };
|
||||
println!("✓ {verb} tool '{name}' ({layer}).");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn toggle_mcp_server(
|
||||
&mut self,
|
||||
action: &str,
|
||||
name: &str,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
let name = name.trim();
|
||||
let enable = match action {
|
||||
"enable" => true,
|
||||
"disable" => false,
|
||||
_ => bail!("Unknown action '{action}'. Usage: .mcp <enable|disable> <server_name>"),
|
||||
};
|
||||
|
||||
if self.agent.is_some() {
|
||||
bail!(
|
||||
"Agent MCP servers are defined by the agent's config ('mcp_servers'); \
|
||||
edit the agent config with `.edit agent-config` instead."
|
||||
);
|
||||
}
|
||||
|
||||
let configured_keys: Vec<String> = match &self.app.mcp_config {
|
||||
Some(mcp_config) if !mcp_config.mcp_servers.is_empty() => {
|
||||
mcp_config.mcp_servers.keys().cloned().collect()
|
||||
}
|
||||
_ => bail!("No MCP servers are configured. Please configure MCP servers first."),
|
||||
};
|
||||
let is_alias = self.app.config.mapping_mcp_servers.contains_key(name);
|
||||
if !configured_keys.iter().any(|v| v == name) && !is_alias {
|
||||
bail!(
|
||||
"MCP server '{name}' is not configured. Run `.list mcp-servers` to see what's available"
|
||||
);
|
||||
}
|
||||
|
||||
let current: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
};
|
||||
let layer = self.write_layer_label();
|
||||
|
||||
let new_list: Vec<String> = if enable {
|
||||
match current {
|
||||
Some(list) if list.iter().any(|s| s.trim() == "all") => {
|
||||
println!("MCP server '{name}' is already enabled ('all' is set).");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if self.mcp_list_covers(&list, name) => {
|
||||
println!("MCP server '{name}' is already enabled.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(mut list) => {
|
||||
list.push(name.to_string());
|
||||
list
|
||||
}
|
||||
None => vec![name.to_string()],
|
||||
}
|
||||
} else {
|
||||
match current {
|
||||
None => {
|
||||
println!("No MCP servers are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if list.is_empty() => {
|
||||
println!("No MCP servers are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(list) if list.iter().any(|s| s.trim() == "all") => {
|
||||
let mut materialized = configured_keys;
|
||||
materialized.retain(|v| v != name);
|
||||
materialized
|
||||
}
|
||||
Some(mut list) => {
|
||||
if list.iter().any(|s| s.trim() == name) {
|
||||
list.retain(|s| s.trim() != name);
|
||||
list
|
||||
} else if self.mcp_list_covers(&list, name) {
|
||||
bail!(
|
||||
"MCP server '{name}' is enabled via an alias in 'mapping_mcp_servers'. \
|
||||
Disable the alias instead, or set the list explicitly with `.set enabled_mcp_servers`."
|
||||
);
|
||||
} else {
|
||||
println!("MCP server '{name}' is not enabled; nothing to do.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !enable && self.skill_registry.loaded_mcp_servers().contains(name) {
|
||||
println!(
|
||||
"Note: '{name}' is granted by a loaded skill and will keep running until that skill is unloaded."
|
||||
);
|
||||
}
|
||||
|
||||
let new_list = Some(new_list);
|
||||
if !self.set_enabled_mcp_servers_on_role_like(new_list.clone()) {
|
||||
self.update_app_config(|app| app.enabled_mcp_servers = new_list.clone());
|
||||
}
|
||||
|
||||
if self.app.config.mcp_server_support {
|
||||
let app = Arc::clone(&self.app.config);
|
||||
self.bootstrap_tools(app.as_ref(), true, abort_signal)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let verb = if enable { "Enabled" } else { "Disabled" };
|
||||
println!("✓ {verb} MCP server '{name}' ({layer}).");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_save_session_on_session(&mut self, value: Option<bool>) -> bool {
|
||||
match self.session.as_mut() {
|
||||
Some(session) => {
|
||||
@@ -1911,6 +2210,127 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_assets(&self, kind: &str) -> Result<()> {
|
||||
match kind {
|
||||
"roles" => print_asset_names("roles", &paths::list_roles(true)),
|
||||
"sessions" => print_asset_names("sessions", &self.list_sessions()),
|
||||
"rags" => print_asset_names("RAGs", &paths::list_rags()),
|
||||
"macros" => print_asset_names("macros", &paths::list_macros()),
|
||||
"agents" => {
|
||||
let names = list_agents();
|
||||
if names.is_empty() {
|
||||
println!("No agents found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Agents:");
|
||||
for name in names {
|
||||
let description = AgentConfig::load(&paths::agent_config_file(&name))
|
||||
.ok()
|
||||
.map(|c| c.description)
|
||||
.filter(|d| !d.is_empty());
|
||||
match description {
|
||||
Some(description) => println!(" • {name} — {description}"),
|
||||
None => println!(" • {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
"skills" => {
|
||||
let policy = SkillPolicy::effective(
|
||||
&self.app.config,
|
||||
self.role.as_ref(),
|
||||
self.agent.as_ref(),
|
||||
self.session.as_ref(),
|
||||
)?;
|
||||
|
||||
if !policy.skills_enabled {
|
||||
bail!("Skills are disabled in this context");
|
||||
}
|
||||
|
||||
let visible_names: Vec<String> = match self.app.config.visible_skills.as_deref() {
|
||||
Some(list) => list.to_vec(),
|
||||
None => paths::list_skills(),
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for name in visible_names {
|
||||
if !policy.compatible_enabled.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let skill = match Skill::load(&name) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("Failed to open skill '{name}' for listing: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let loaded = self.skill_registry.is_loaded(skill.name());
|
||||
entries.push((
|
||||
skill.name().to_string(),
|
||||
skill.description().to_string(),
|
||||
loaded,
|
||||
));
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No skills found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Skills:");
|
||||
for (name, description, loaded) in entries {
|
||||
let marker = if loaded { " (loaded)" } else { "" };
|
||||
println!(" • {name}{marker} — {description}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
"tools" => {
|
||||
let mut names: Vec<String> = self
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
!v.name.starts_with("user__")
|
||||
&& !v.name.starts_with("mcp_")
|
||||
&& !v.name.starts_with("todo__")
|
||||
&& !v.name.starts_with("agent__")
|
||||
})
|
||||
.map(|v| v.name.clone())
|
||||
.collect();
|
||||
names.extend(self.app.config.mapping_tools.keys().map(|v| v.to_string()));
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
print_asset_names("tools", &names)
|
||||
}
|
||||
"mcp-servers" => {
|
||||
let mut names: Vec<String> = vec![];
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
names.extend(mcp_config.mcp_servers.keys().map(|v| v.to_string()));
|
||||
}
|
||||
names.extend(
|
||||
self.app
|
||||
.config
|
||||
.mapping_mcp_servers
|
||||
.keys()
|
||||
.map(|v| v.to_string()),
|
||||
);
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
print_asset_names("MCP servers", &names)
|
||||
}
|
||||
_ => bail!(
|
||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, kind: &str) -> Result<()> {
|
||||
let (dir, file_ext) = match kind {
|
||||
"role" => (paths::roles_dir(), Some(".md")),
|
||||
@@ -2327,7 +2747,16 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
".rag" => super::map_completion_values(paths::list_rags()),
|
||||
".agent" => super::map_completion_values(list_agents()),
|
||||
".agent" => list_agents()
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let description = AgentConfig::load(&paths::agent_config_file(&name))
|
||||
.ok()
|
||||
.map(|c| c.description)
|
||||
.filter(|d| !d.is_empty());
|
||||
(name, description)
|
||||
})
|
||||
.collect(),
|
||||
".install" => {
|
||||
let mut values: Vec<String> =
|
||||
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
||||
@@ -2391,6 +2820,16 @@ impl RequestContext {
|
||||
"skill",
|
||||
"agent-data",
|
||||
]),
|
||||
".list" => super::map_completion_values(vec![
|
||||
"roles",
|
||||
"sessions",
|
||||
"agents",
|
||||
"rags",
|
||||
"macros",
|
||||
"skills",
|
||||
"tools",
|
||||
"mcp-servers",
|
||||
]),
|
||||
".vault" => {
|
||||
let mut values = vec!["add", "get", "update", "delete", "list"];
|
||||
values.sort_unstable();
|
||||
@@ -2412,12 +2851,91 @@ impl RequestContext {
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
} else if cmd == ".mcp"
|
||||
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
|
||||
&& args.len() == 2
|
||||
{
|
||||
let current = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
}
|
||||
.unwrap_or_default();
|
||||
let has_all = current.iter().any(|s| s.trim() == "all");
|
||||
|
||||
let candidates: Vec<String> = if args.first() == Some(&"enable") {
|
||||
if has_all {
|
||||
vec![]
|
||||
} else {
|
||||
let mut candidates: Vec<String> = vec![];
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
candidates.extend(mcp_config.mcp_servers.keys().cloned());
|
||||
}
|
||||
candidates.extend(self.app.config.mapping_mcp_servers.keys().cloned());
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
candidates.retain(|v| !self.mcp_list_covers(¤t, v));
|
||||
|
||||
candidates
|
||||
}
|
||||
} else if has_all {
|
||||
self.app
|
||||
.mcp_config
|
||||
.as_ref()
|
||||
.map(|c| c.mcp_servers.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
current
|
||||
.iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s != "all" && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
values = super::map_completion_values(candidates);
|
||||
} else if cmd == ".tool"
|
||||
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
|
||||
&& args.len() == 2
|
||||
{
|
||||
let current = if let Some(session) = &self.session {
|
||||
session.enabled_tools()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_tools()
|
||||
} else {
|
||||
self.app.config.enabled_tools.clone()
|
||||
}
|
||||
.unwrap_or_default();
|
||||
let has_all = current.iter().any(|s| s.trim() == "all");
|
||||
|
||||
let candidates: Vec<String> = if args.first() == Some(&"enable") {
|
||||
if has_all {
|
||||
vec![]
|
||||
} else {
|
||||
let mut candidates = self.concrete_tool_names();
|
||||
candidates.extend(self.app.config.mapping_tools.keys().cloned());
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
candidates.retain(|v| !self.tool_list_covers(¤t, v));
|
||||
|
||||
candidates
|
||||
}
|
||||
} else if has_all {
|
||||
self.concrete_tool_names()
|
||||
} else {
|
||||
current
|
||||
.iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s != "all" && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
values = super::map_completion_values(candidates);
|
||||
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
||||
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
||||
{
|
||||
values = super::map_completion_values(paths::list_skills());
|
||||
values = complete_skills_with_descriptions(paths::list_skills());
|
||||
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
|
||||
values = super::map_completion_values(self.skill_registry.loaded_names());
|
||||
values = complete_skills_with_descriptions(self.skill_registry.loaded_names());
|
||||
} else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 {
|
||||
let prev = args.get(args.len() - 2).copied().unwrap_or("");
|
||||
if prev == "--filter" {
|
||||
@@ -2643,18 +3161,25 @@ impl RequestContext {
|
||||
let app_ref = &self.app;
|
||||
let acquire_all = async {
|
||||
let mut handles = Vec::new();
|
||||
let mut auth_required = Vec::new();
|
||||
for id in &server_ids {
|
||||
if let Some(spec) = mcp_config.mcp_servers.get(id) {
|
||||
let handle = app_ref
|
||||
match app_ref
|
||||
.mcp_factory
|
||||
.acquire(id, spec, app_ref.mcp_log_path.as_deref())
|
||||
.await?;
|
||||
handles.push((id.clone(), handle));
|
||||
.await
|
||||
{
|
||||
Ok(handle) => handles.push((id.clone(), handle)),
|
||||
Err(e) if is_auth_required_error(&e) => {
|
||||
auth_required.push(id.clone())
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok::<_, Error>(handles)
|
||||
}
|
||||
Ok::<_, Error>((handles, auth_required))
|
||||
};
|
||||
let handles = abortable_run_with_spinner(
|
||||
let (handles, auth_required) = abortable_run_with_spinner(
|
||||
acquire_all,
|
||||
"Loading MCP servers",
|
||||
abort_signal.clone(),
|
||||
@@ -2663,6 +3188,12 @@ impl RequestContext {
|
||||
for (id, handle) in handles {
|
||||
mcp_runtime.insert(id, handle);
|
||||
}
|
||||
for id in auth_required {
|
||||
eprintln!(
|
||||
"Warning: MCP server '{id}' requires OAuth authentication and was not started. \
|
||||
Run `.mcp auth {id}` (or `coyote --auth-mcp {id}`) to authenticate and attach it."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2763,9 +3294,18 @@ impl RequestContext {
|
||||
);
|
||||
}
|
||||
}
|
||||
let prev_role = self.role.clone();
|
||||
let prev_session = self.session.clone();
|
||||
self.use_role_obj(role)?;
|
||||
self.rebuild_tool_scope(app, mcp_servers, abort_signal)
|
||||
if let Err(e) = self
|
||||
.rebuild_tool_scope(app, mcp_servers, abort_signal)
|
||||
.await
|
||||
{
|
||||
self.role = prev_role;
|
||||
self.session = prev_session;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn use_session(
|
||||
@@ -3793,6 +4333,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn app_state_with_mcp_config(mcp_server_support: bool, server_names: &[&str]) -> Arc<AppState> {
|
||||
app_state_with_mcp_command(mcp_server_support, server_names, "echo")
|
||||
}
|
||||
|
||||
fn app_state_with_mcp_command(
|
||||
mcp_server_support: bool,
|
||||
server_names: &[&str],
|
||||
command: &str,
|
||||
) -> Arc<AppState> {
|
||||
let app_config = AppConfig {
|
||||
mcp_server_support,
|
||||
..AppConfig::default()
|
||||
@@ -3807,7 +4355,7 @@ mod tests {
|
||||
name.to_string(),
|
||||
McpServer {
|
||||
transport_type: McpTransportType::Stdio,
|
||||
command: Some("echo".to_string()),
|
||||
command: Some(command.to_string()),
|
||||
args: None,
|
||||
env: None,
|
||||
cwd: None,
|
||||
@@ -3856,6 +4404,33 @@ mod tests {
|
||||
assert!(ctx.tool_scope.mcp_runtime.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn use_role_rolls_back_when_mcp_startup_fails() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let roles_dir = paths::roles_dir();
|
||||
create_dir_all(&roles_dir).unwrap();
|
||||
write(
|
||||
roles_dir.join("broken_mcp.md"),
|
||||
"---\nenabled_mcp_servers: failing\n---\nYou use MCP servers.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let app_state =
|
||||
app_state_with_mcp_command(true, &["failing"], "/nonexistent/coyote-test-mcp-binary");
|
||||
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
let app = ctx.app.config.clone();
|
||||
let abort = utils::create_abort_signal();
|
||||
|
||||
let result = run_async(ctx.use_role(&app, "broken_mcp", abort));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
ctx.role.is_none(),
|
||||
"role must be rolled back when MCP startup fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn rebuild_tool_scope_no_enabled_servers_yields_empty_runtime() {
|
||||
|
||||
+25
-4
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
|
||||
parent_ctx: &mut RequestContext,
|
||||
is_repair: bool,
|
||||
) -> Result<Value> {
|
||||
let role = build_extractor_role()?;
|
||||
let role = build_extractor_role(parent_ctx);
|
||||
let prompt = build_extractor_prompt(raw, schema, is_repair);
|
||||
|
||||
let saved_role = parent_ctx.role.clone();
|
||||
@@ -53,11 +53,12 @@ async fn extract_via_extractor(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_extractor_role() -> Result<Role> {
|
||||
fn build_extractor_role(ctx: &RequestContext) -> Role {
|
||||
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
|
||||
role.set_model(ctx.current_model().clone());
|
||||
role.set_enabled_tools(Some(Vec::new()));
|
||||
role.set_enabled_mcp_servers(Some(Vec::new()));
|
||||
Ok(role)
|
||||
role
|
||||
}
|
||||
|
||||
fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String {
|
||||
@@ -107,8 +108,14 @@ fn strip_code_fences(s: &str) -> &str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::Model;
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use serde_json::json;
|
||||
|
||||
fn make_ctx() -> RequestContext {
|
||||
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_accepts_plain_object() {
|
||||
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
|
||||
@@ -181,9 +188,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_extractor_role_disables_tools_and_mcp() {
|
||||
let role = build_extractor_role().expect("builtin role must exist");
|
||||
let ctx = make_ctx();
|
||||
|
||||
let role = build_extractor_role(&ctx);
|
||||
|
||||
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
|
||||
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_extractor_role_uses_parent_context_model() {
|
||||
let mut ctx = make_ctx();
|
||||
let mut parent_role = Role::new("parent", "parent prompt");
|
||||
parent_role.set_model(Model::new("client-x", "model-y"));
|
||||
ctx.role = Some(parent_role);
|
||||
|
||||
let role = build_extractor_role(&ctx);
|
||||
|
||||
assert_eq!(role.model().id(), "client-x:model-y");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1061,4 +1061,14 @@ mod tests {
|
||||
|
||||
assert!(!is_auth_required_error(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_auth_required_error_survives_context_wrapping() {
|
||||
let e = anyhow!("Auth required, when send initialize request").context(
|
||||
"MCP server 'github' requires OAuth authentication. \
|
||||
Run `coyote --auth-mcp github` or `.mcp auth github` in the REPL to authenticate.",
|
||||
);
|
||||
|
||||
assert!(is_auth_required_error(&e));
|
||||
}
|
||||
}
|
||||
|
||||
+193
-20
@@ -14,6 +14,8 @@ use url::Url;
|
||||
struct ProtectedResourceMetadata {
|
||||
#[serde(default)]
|
||||
authorization_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
scopes_supported: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||
let base = extract_base_url(server_url)?;
|
||||
let client = Client::new();
|
||||
let mut tried: Vec<String> = Vec::new();
|
||||
|
||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
||||
if let Ok(resp) = client.get(&pr_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
||||
{
|
||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
||||
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
|
||||
// WWW-Authenticate challenge advertises the protected resource metadata URL.
|
||||
let mut pr_urls = Vec::new();
|
||||
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
|
||||
pr_urls.push(url);
|
||||
}
|
||||
|
||||
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
|
||||
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
|
||||
pr_urls.dedup();
|
||||
|
||||
for pr_url in &pr_urls {
|
||||
tried.push(pr_url.clone());
|
||||
let Ok(resp) = client.get(pr_url).send().await else {
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
continue;
|
||||
}
|
||||
let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await else {
|
||||
continue;
|
||||
};
|
||||
let Some(issuer) = pr.authorization_servers.first() else {
|
||||
continue;
|
||||
};
|
||||
// RFC 8414 @ 3.1: for issuers with a path component the well-known
|
||||
// segment is inserted BEFORE the path (with the legacy appended form
|
||||
// and root as fallbacks).
|
||||
for as_url in well_known_urls(issuer, "oauth-authorization-server")? {
|
||||
tried.push(as_url.clone());
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
||||
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
|
||||
{
|
||||
// Some auth servers (e.g. GitHub) omit scopes_supported from
|
||||
// their metadata; fall back to the resource's advertised scopes.
|
||||
if meta.scopes_supported.is_empty() {
|
||||
meta.scopes_supported = pr.scopes_supported.clone();
|
||||
}
|
||||
return Ok(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let as_url = format!("{base}/.well-known/oauth-authorization-server");
|
||||
let resp = client
|
||||
.get(&as_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
// Last resort: the MCP server itself may host authorization server metadata.
|
||||
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
|
||||
tried.push(as_url.clone());
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
{
|
||||
return resp
|
||||
.json::<OAuthServerMetadata>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||
Tried:\n {pr_url}\n {as_url}\n\
|
||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
||||
Tried:\n {}\n\
|
||||
Ensure the server supports MCP OAuth discovery, or consult its documentation.",
|
||||
tried.join("\n ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Probes the MCP server with an unauthenticated request and extracts the
|
||||
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
|
||||
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
|
||||
let resp = client.get(server_url).send().await.ok()?;
|
||||
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
|
||||
|
||||
parse_resource_metadata(header.to_str().ok()?)
|
||||
}
|
||||
|
||||
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
|
||||
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
|
||||
fn parse_resource_metadata(challenge: &str) -> Option<String> {
|
||||
let (_, rest) = challenge.split_once("resource_metadata=")?;
|
||||
let rest = rest.trim_start();
|
||||
let value = if let Some(stripped) = rest.strip_prefix('"') {
|
||||
stripped.split('"').next()?
|
||||
} else {
|
||||
rest.split([',', ' ']).next()?
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
|
||||
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
|
||||
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
|
||||
/// 3. Root: `{origin}/.well-known/{suffix}`
|
||||
///
|
||||
/// URLs without a path component yield only the root form.
|
||||
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
|
||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||
let origin = extract_base_url(url)?;
|
||||
let path = parsed.path().trim_end_matches('/');
|
||||
|
||||
let mut urls = Vec::new();
|
||||
if !path.is_empty() && path != "/" {
|
||||
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
|
||||
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
|
||||
}
|
||||
urls.push(format!("{origin}/.well-known/{suffix}"));
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
fn extract_base_url(url: &str) -> Result<String> {
|
||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||
let scheme = parsed.scheme();
|
||||
@@ -296,6 +374,101 @@ mod tests {
|
||||
assert!(extract_base_url("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_path_aware_first_for_url_with_path() {
|
||||
let urls = well_known_urls(
|
||||
"https://api.githubcopilot.com/mcp",
|
||||
"oauth-protected-resource",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
|
||||
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_inserts_before_issuer_path() {
|
||||
let urls = well_known_urls(
|
||||
"https://github.com/login/oauth",
|
||||
"oauth-authorization-server",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_root_only_for_url_without_path() {
|
||||
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_ignores_trailing_slash() {
|
||||
let urls = well_known_urls(
|
||||
"https://api.githubcopilot.com/mcp/",
|
||||
"oauth-protected-resource",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_extracts_quoted_url() {
|
||||
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
|
||||
|
||||
let url = parse_resource_metadata(challenge);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
Some(
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_extracts_unquoted_url() {
|
||||
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
|
||||
|
||||
let url = parse_resource_metadata(challenge);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_returns_none_when_absent() {
|
||||
assert_eq!(
|
||||
parse_resource_metadata(r#"Bearer error="invalid_token""#),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_resource_metadata(r#"Bearer resource_metadata="""#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_roundtrip() {
|
||||
|
||||
+94
-4
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -71,6 +71,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
|
||||
"Authenticate with an MCP server via OAuth",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".mcp enable",
|
||||
"Enable a single MCP server in the current context",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".mcp disable",
|
||||
"Disable a single MCP server in the current context",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".tool enable",
|
||||
"Enable a single tool in the current context",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".tool disable",
|
||||
"Disable a single tool in the current context",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".edit config",
|
||||
"Modify configuration file",
|
||||
@@ -269,6 +289,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
|
||||
"Delete roles, sessions, RAGs, or agents",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".list",
|
||||
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".vault",
|
||||
"View or modify the Coyote vault",
|
||||
@@ -673,14 +698,69 @@ pub async fn run_repl_command(
|
||||
)
|
||||
.await?;
|
||||
println!("Authentication saved.");
|
||||
if ctx.app.config.mcp_server_support {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.bootstrap_tools(
|
||||
app.as_ref(),
|
||||
true,
|
||||
abort_signal.clone(),
|
||||
)
|
||||
.await?;
|
||||
if ctx.tool_scope.mcp_runtime.get(server_name).is_some()
|
||||
{
|
||||
println!(
|
||||
"✓ MCP server '{server_name}' started and attached to the current context."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"MCP server '{server_name}' is not enabled in the current context. \
|
||||
Run `.mcp enable {server_name}` to attach it."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"enable" | "disable" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: .mcp {sub} <server_name>");
|
||||
} else {
|
||||
ctx.toggle_mcp_server(sub, rest, abort_signal.clone())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
_ => unknown_command()?,
|
||||
}
|
||||
}
|
||||
None => println!("Usage: .mcp auth <server_name>"),
|
||||
None => println!(
|
||||
r#"Usage:
|
||||
.mcp auth <server_name> # Authenticate with an MCP server via OAuth
|
||||
.mcp enable <server_name> # Enable a single MCP server in the current context
|
||||
.mcp disable <server_name> # Disable a single MCP server in the current context"#
|
||||
),
|
||||
},
|
||||
".tool" => match args {
|
||||
Some(args) => {
|
||||
let mut parts = args.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").trim();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
match sub {
|
||||
"enable" | "disable" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: .tool {sub} <name>");
|
||||
} else {
|
||||
ctx.toggle_tool(sub, rest)?;
|
||||
}
|
||||
}
|
||||
_ => unknown_command()?,
|
||||
}
|
||||
}
|
||||
None => println!(
|
||||
r#"Usage:
|
||||
.tool enable <name> # Enable a single tool in the current context
|
||||
.tool disable <name> # Disable a single tool in the current context"#
|
||||
),
|
||||
},
|
||||
".prompt" => match args {
|
||||
Some(text) => {
|
||||
@@ -1092,6 +1172,16 @@ pub async fn run_repl_command(
|
||||
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
||||
}
|
||||
},
|
||||
".list" => match args {
|
||||
Some(args) => {
|
||||
ctx.list_assets(args.trim())?;
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
|
||||
)
|
||||
}
|
||||
},
|
||||
".copy" => {
|
||||
let output = match ctx
|
||||
.last_message
|
||||
@@ -1614,8 +1704,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_52_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 52);
|
||||
fn repl_commands_has_57_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 57);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user